public class

ShortcutInfoCompatSaverImpl

extends ShortcutInfoCompatSaver<>

 java.lang.Object

androidx.core.content.pm.ShortcutInfoCompatSaver<>

↳androidx.sharetarget.ShortcutInfoCompatSaverImpl

Gradle dependencies

compile group: 'androidx.sharetarget', name: 'sharetarget', version: '1.2.0-rc01'

  • groupId: androidx.sharetarget
  • artifactId: sharetarget
  • version: 1.2.0-rc01

Artifact androidx.sharetarget:sharetarget:1.2.0-rc01 it located at Google repository (https://maven.google.com/)

Overview

Provides APIs to access and update a persistable list of ShortcutInfoCompat. This class keeps an up-to-date cache of the complete list in memory for quick access, except shortcuts' Icons, which are stored on the disk and only loaded from disk separately if necessary.

Summary

Methods
public <any>addShortcuts(java.util.List<ShortcutInfoCompat> shortcuts)

public static ShortcutInfoCompatSaverImplgetInstance(Context context)

public IconCompatgetShortcutIcon(java.lang.String shortcutId)

public java.util.List<ShortcutInfoCompat>getShortcuts()

public <any>removeAllShortcuts()

public <any>removeShortcuts(java.util.List<java.lang.String> shortcutIds)

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Methods

public static ShortcutInfoCompatSaverImpl getInstance(Context context)

public <any> removeShortcuts(java.util.List<java.lang.String> shortcutIds)

public <any> removeAllShortcuts()

public java.util.List<ShortcutInfoCompat> getShortcuts()

public IconCompat getShortcutIcon(java.lang.String shortcutId)

public <any> addShortcuts(java.util.List<ShortcutInfoCompat> shortcuts)

Source

/*
 * Copyright 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.sharetarget;

import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.text.TextUtils;
import android.util.Log;

import androidx.annotation.AnyThread;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.annotation.WorkerThread;
import androidx.collection.ArrayMap;
import androidx.concurrent.futures.ResolvableFuture;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutInfoCompatSaver;
import androidx.core.graphics.drawable.IconCompat;
import androidx.sharetarget.ShortcutsInfoSerialization.ShortcutContainer;

import com.google.common.util.concurrent.ListenableFuture;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * Provides APIs to access and update a persistable list of {@link ShortcutInfoCompat}. This class
 * keeps an up-to-date cache of the complete list in memory for quick access, except shortcuts'
 * Icons, which are stored on the disk and only loaded from disk separately if necessary.
 *
 * @hide
 */
@RequiresApi(19)
@RestrictTo(LIBRARY_GROUP_PREFIX)
//TODO: we need Futures.addCallback and CallbackToFutureAdapter, update once they're available
public class ShortcutInfoCompatSaverImpl extends ShortcutInfoCompatSaver<ListenableFuture<Void>> {

    static final String TAG = "ShortcutInfoCompatSaver";

    private static final String DIRECTORY_TARGETS = "ShortcutInfoCompatSaver_share_targets";
    private static final String DIRECTORY_BITMAPS = "ShortcutInfoCompatSaver_share_targets_bitmaps";
    private static final String FILENAME_XML = "targets.xml";
    // The maximum time background idle threads will wait for new tasks before terminating
    private static final int EXECUTOR_KEEP_ALIVE_TIME_SECS = 20;
    private static final Object GET_INSTANCE_LOCK = new Object();

    private static volatile ShortcutInfoCompatSaverImpl sInstance;

    @SuppressWarnings("WeakerAccess")
    final Context mContext;
    // mShortcutsMap is strictly only accessed by mCacheUpdateService
    @SuppressWarnings("WeakerAccess")
    final Map<String, ShortcutContainer> mShortcutsMap = new ArrayMap<>();
    @SuppressWarnings("WeakerAccess")
    final Map<String, ListenableFuture<?>> mScheduledBitmapTasks = new ArrayMap<>();

    @SuppressWarnings("WeakerAccess")
    final ExecutorService mCacheUpdateService;
    // Single threaded tasks queue for IO operations on disk
    private final ExecutorService mDiskIoService;

    @SuppressWarnings("WeakerAccess")
    final File mTargetsXmlFile;
    @SuppressWarnings("WeakerAccess")
    final File mBitmapsDir;

    @AnyThread
    public static ShortcutInfoCompatSaverImpl getInstance(Context context) {
        if (sInstance == null) {
            synchronized (GET_INSTANCE_LOCK) {
                if (sInstance == null) {
                    sInstance = new ShortcutInfoCompatSaverImpl(context,
                            createExecutorService(),
                            createExecutorService());
                }
            }
        }
        return sInstance;
    }

    @AnyThread
    static ExecutorService createExecutorService() {
        return new ThreadPoolExecutor(
                // Set to 0 to avoid persistent background thread when idle
                0, /* core pool size */
                // Set to 1 to ensure tasks will run strictly in the submit order
                1, /* max pool size */
                EXECUTOR_KEEP_ALIVE_TIME_SECS, /* keep alive time */
                TimeUnit.SECONDS, /* keep alive time unit */
                new LinkedBlockingQueue<Runnable>() /* Not used */);
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    @AnyThread
    ShortcutInfoCompatSaverImpl(Context context, ExecutorService cacheUpdateService,
            ExecutorService diskIoService) {
        mContext = context.getApplicationContext();
        mCacheUpdateService = cacheUpdateService;
        mDiskIoService = diskIoService;
        final File workingDirectory = new File(context.getFilesDir(), DIRECTORY_TARGETS);
        mBitmapsDir = new File(workingDirectory, DIRECTORY_BITMAPS);
        mTargetsXmlFile = new File(workingDirectory, FILENAME_XML);
        // we trying to recover from errors during following submit:
        // if xml was corrupted it is removed and saver is started clean
        // however it is still not great and there is chance to swallow an exception
        mCacheUpdateService.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    ensureDir(workingDirectory);
                    ensureDir(mBitmapsDir);
                    mShortcutsMap.putAll(ShortcutsInfoSerialization.loadFromXml(mTargetsXmlFile,
                            mContext));
                    deleteDanglingBitmaps(new ArrayList<>(mShortcutsMap.values()));
                } catch (Exception e) {
                    Log.w(TAG, "ShortcutInfoCompatSaver started with an exceptions ", e);
                }
            }
        });
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    @AnyThread
    @Override
    public ListenableFuture<Void> removeShortcuts(List<String> shortcutIds) {
        final List<String> idList = new ArrayList<>(shortcutIds);
        final ResolvableFuture<Void> result = ResolvableFuture.create();
        mCacheUpdateService.submit(new Runnable() {
            @Override
            public void run() {
                for (String id : idList) {
                    mShortcutsMap.remove(id);
                    ListenableFuture<?> removed = mScheduledBitmapTasks.remove(id);
                    if (removed != null) {
                        removed.cancel(false);
                    }
                }
                scheduleSyncCurrentState(result);
            }
        });
        return result;
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    @AnyThread
    @Override
    public ListenableFuture<Void> removeAllShortcuts() {
        final ResolvableFuture<Void> result =
                ResolvableFuture.create();
        mCacheUpdateService.submit(new Runnable() {
            @Override
            public void run() {
                mShortcutsMap.clear();
                for (ListenableFuture<?> task : mScheduledBitmapTasks.values()) {
                    task.cancel(false);
                }
                mScheduledBitmapTasks.clear();
                scheduleSyncCurrentState(result);
            }
        });

        return result;
    }

    @WorkerThread
    @Override
    public List<ShortcutInfoCompat> getShortcuts() throws Exception {
        return mCacheUpdateService.submit(new Callable<ArrayList<ShortcutInfoCompat>>() {
            @Override
            public ArrayList<ShortcutInfoCompat> call() {
                ArrayList<ShortcutInfoCompat> shortcuts = new ArrayList<>();
                for (ShortcutContainer item : mShortcutsMap.values()) {
                    shortcuts.add(new ShortcutInfoCompat.Builder(item.mShortcutInfo).build());
                }
                return shortcuts;
            }
        }).get();
    }

    @WorkerThread
    public IconCompat getShortcutIcon(final String shortcutId) throws Exception {
        final ShortcutContainer container = mCacheUpdateService.submit(
                new Callable<ShortcutContainer>() {
                    @Override
                    public ShortcutContainer call() {
                        return mShortcutsMap.get(shortcutId);
                    }
                }).get();
        if (container == null) {
            return null;
        }
        if (!TextUtils.isEmpty(container.mResourceName)) {
            int id = 0;
            try {
                id = mContext.getResources().getIdentifier(container.mResourceName, null, null);
            } catch (Exception e) {
                /* Do nothing, continue and try mBitmapPath */
            }
            if (id != 0) {
                return IconCompat.createWithResource(mContext, id);
            }
        }
        if (!TextUtils.isEmpty(container.mBitmapPath)) {
            Bitmap bitmap = mDiskIoService.submit(new Callable<Bitmap>() {
                @Override
                public Bitmap call() {
                    return BitmapFactory.decodeFile(container.mBitmapPath);
                }
            }).get();
            // TODO: Re-create an adaptive icon if the original icon was adaptive
            return bitmap != null ? IconCompat.createWithBitmap(bitmap) : null;
        }
        return null;
    }

    /**
     * Delete bitmap files from the disk if they are not associated with any shortcuts in the list.
     *
     * Strictly called by mDiskIoService only
     */
    @SuppressWarnings("WeakerAccess")
    void deleteDanglingBitmaps(List<ShortcutContainer> shortcutsList) {
        List<String> bitmapPaths = new ArrayList<>();
        for (ShortcutContainer item : shortcutsList) {
            if (!TextUtils.isEmpty(item.mBitmapPath)) {
                bitmapPaths.add(item.mBitmapPath);
            }
        }
        for (File bitmap : mBitmapsDir.listFiles()) {
            if (!bitmapPaths.contains(bitmap.getAbsolutePath())) {
                bitmap.delete();
            }
        }
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    @AnyThread
    @Override
    public ListenableFuture<Void> addShortcuts(List<ShortcutInfoCompat> shortcuts) {
        final List<ShortcutInfoCompat> copy = new ArrayList<>(shortcuts.size());
        for (ShortcutInfoCompat infoCompat : shortcuts) {
            copy.add(new ShortcutInfoCompat.Builder(infoCompat).build());
        }
        final ResolvableFuture<Void> result = ResolvableFuture.create();
        mCacheUpdateService.submit(new Runnable() {
            @Override
            public void run() {
                for (final ShortcutInfoCompat info : copy) {
                    Set<String> categories = info.getCategories();
                    if (categories == null || categories.isEmpty()) {
                        continue;
                    }
                    ShortcutContainer container = containerFrom(info);
                    IconCompat icon = info.getIcon();
                    // not null only if it is safe to call getBitmap
                    Bitmap bitmap = container.mBitmapPath != null ? icon.getBitmap() : null;
                    final String id = info.getId();
                    mShortcutsMap.put(id, container);
                    if (bitmap != null) {
                        final ListenableFuture<Void> future = scheduleBitmapSaving(bitmap,
                                container.mBitmapPath);
                        ListenableFuture<?> old = mScheduledBitmapTasks.put(id, future);
                        if (old != null) {
                            old.cancel(false);
                        }
                        future.addListener(new Runnable() {
                            @Override
                            public void run() {
                                mScheduledBitmapTasks.remove(id);
                                // saving bitmap was skipped, but it is okay
                                if (future.isCancelled()) {
                                    return;
                                }
                                try {
                                    future.get();
                                } catch (Exception e) {
                                    // propagate an exception up to the chain.
                                    result.setException(e);
                                }
                            }
                        }, mCacheUpdateService);
                    }
                }
                scheduleSyncCurrentState(result);
            }
        });
        return result;
    }

    @SuppressWarnings("WeakerAccess")
    ListenableFuture<Void> scheduleBitmapSaving(final Bitmap bitmap, final String path) {
        return submitDiskOperation(new Runnable() {
            @Override
            public void run() {
                saveBitmap(bitmap, path);
            }
        });
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    private ListenableFuture<Void> submitDiskOperation(final Runnable runnable) {
        final ResolvableFuture<Void> result = ResolvableFuture.create();
        mDiskIoService.submit(new Runnable() {
            @Override
            public void run() {
                if (result.isCancelled()) {
                    return;
                }
                try {
                    runnable.run();
                    result.set(null);
                } catch (Exception e) {
                    result.setException(e);
                }
            }
        });
        return result;
    }

    // must be called on mCacheUpdateService
    @SuppressWarnings("WeakerAccess")
    void scheduleSyncCurrentState(final ResolvableFuture<Void> output) {
        final List<ShortcutContainer> containers = new ArrayList<>(mShortcutsMap.values());
        final ListenableFuture<Void> future = submitDiskOperation(new Runnable() {
            @Override
            public void run() {
                deleteDanglingBitmaps(containers);
                ShortcutsInfoSerialization.saveAsXml(containers, mTargetsXmlFile);
            }
        });
        future.addListener(new Runnable() {
            @Override
            public void run() {
                try {
                    future.get();
                    output.set(null);
                } catch (Exception e) {
                    output.setException(e);
                }
            }
        }, mCacheUpdateService);
    }

    @SuppressWarnings("WeakerAccess")
    ShortcutContainer containerFrom(ShortcutInfoCompat shortcut) {
        String resourceName = null;
        String bitmapPath = null;
        IconCompat icon = shortcut.getIcon();
        if (icon != null) {
            switch (icon.getType()) {
                case IconCompat.TYPE_RESOURCE:
                    resourceName = mContext.getResources().getResourceName(icon.getResId());
                    break;
                case IconCompat.TYPE_BITMAP:
                case IconCompat.TYPE_ADAPTIVE_BITMAP:
                    // Choose a unique file name to serialize the bitmap
                    bitmapPath = new File(mBitmapsDir, UUID.randomUUID().toString())
                            .getAbsolutePath();
                    break;
                case IconCompat.TYPE_DATA:
                case IconCompat.TYPE_URI:
                case IconCompat.TYPE_URI_ADAPTIVE_BITMAP:
                case IconCompat.TYPE_UNKNOWN:
                    break;
            }
        }
        ShortcutInfoCompat shortcutCopy = new ShortcutInfoCompat.Builder(shortcut)
                .setIcon(null).build();
        return new ShortcutContainer(shortcutCopy, resourceName, bitmapPath);
    }

    /*
     * Suppress wrong thread warning since Bitmap.compress() and saveBitmap() are both annotated
     * @WorkerThread, but from different packages.
     * androidx.annotation.WorkerThread vs android.annotation.WorkerThread
     */
    @WorkerThread
    @SuppressWarnings("WrongThread")
    void saveBitmap(Bitmap bitmap, String path) {
        if (bitmap == null) {
            throw new IllegalArgumentException("bitmap is null");
        }
        if (TextUtils.isEmpty(path)) {
            throw new IllegalArgumentException("path is empty");
        }

        try (FileOutputStream fileStream = new FileOutputStream(new File(path))) {
            if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100 /* quality */, fileStream)) {
                Log.wtf(TAG, "Unable to compress bitmap");
                throw new RuntimeException("Unable to compress bitmap for saving " + path);
            }
        } catch (IOException | RuntimeException | OutOfMemoryError e) {
            Log.wtf(TAG, "Unable to write bitmap to file", e);
            throw new RuntimeException("Unable to write bitmap to file " + path, e);
        }
    }

    static boolean ensureDir(File directory) {
        if (directory.exists() && !directory.isDirectory() && !directory.delete()) {
            return false;
        }
        if (!directory.exists()) {
            return directory.mkdirs();
        }
        return true;
    }
}