Browse Source

增加任意目录扫描

zk 1 year ago
parent
commit
24ffc6385f

+ 56 - 0
app/src/main/java/com/datarecovery/master/data/repositories/FileScanRepository.java

@@ -0,0 +1,56 @@
+package com.datarecovery.master.data.repositories;
+
+
+import com.atmob.common.logging.AtmobLog;
+import com.atmob.common.runtime.ContextUtil;
+import com.datarecovery.master.utils.filedetect.DetectFile;
+import com.datarecovery.master.utils.filedetect.FileScanHelper;
+import com.datarecovery.master.utils.xfile.XFile;
+
+import org.reactivestreams.Subscription;
+
+import java.util.List;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import atmob.reactivex.rxjava3.annotations.NonNull;
+import atmob.reactivex.rxjava3.core.FlowableSubscriber;
+
+@Singleton
+public class FileScanRepository {
+
+    private static final String TAG = "FileScanRepository";
+
+    @Inject
+    public FileScanRepository() {
+
+    }
+
+    public void refreshImageDetect(@FileScanHelper.RefreshScanType int type) {
+        FileScanHelper.refreshImageDetect(ContextUtil.getContext(), type)
+                .subscribe(new FlowableSubscriber<List<DetectFile>>() {
+                    @Override
+                    public void onSubscribe(@NonNull Subscription s) {
+
+                    }
+
+                    @Override
+                    public void onNext(List<DetectFile> fileList) {
+                        for (DetectFile file : fileList) {
+                            AtmobLog.d(TAG, file.getPath());
+                        }
+                    }
+
+                    @Override
+                    public void onError(Throwable t) {
+
+                    }
+
+                    @Override
+                    public void onComplete() {
+
+                    }
+                });
+    }
+}

+ 2 - 1
app/src/main/java/com/datarecovery/master/module/main/MainViewModel.java

@@ -2,6 +2,7 @@ package com.datarecovery.master.module.main;
 
 import com.atmob.app.lib.base.BaseViewModel;
 import com.datarecovery.master.data.repositories.ConfigRepository;
+import com.datarecovery.master.data.repositories.FileScanRepository;
 import com.datarecovery.master.data.repositories.MemberRepository;
 import com.datarecovery.master.utils.OrderReportHelper;
 import com.google.gson.Gson;
@@ -15,7 +16,7 @@ public class MainViewModel extends BaseViewModel {
 
 
     @Inject
-    public MainViewModel(Gson gson, MemberRepository memberRepository, ConfigRepository configRepository) {
+    public MainViewModel(Gson gson, MemberRepository memberRepository, ConfigRepository configRepository, FileScanRepository fileScanRepository) {
         OrderReportHelper.init(gson, memberRepository);
     }
 }

+ 77 - 0
app/src/main/java/com/datarecovery/master/utils/BrandUtil.java

@@ -0,0 +1,77 @@
+package com.datarecovery.master.utils;
+
+import android.os.Build;
+
+public class BrandUtil {
+    public static final int HuaWei = 1;
+    public static final int XiaoMi = 2;
+    public static final int Vivo = 3;
+    public static final int Oppo = 4;
+    public static final int Meizu = 5;
+    public static final int Samsung = 6;
+    public static final int Smartisan = 7;
+    public static final int Lenovo = 8;
+    public static final int ZTE = 9;
+    public static final int OnePlus = 10;
+    public static final int Google = 11;
+    public static final int Sony = 12;
+    public static final int LG = 13;
+    public static final int HTC = 14;
+    public static final int ASUS = 15;
+    public static final int GIONEE = 16;
+    public static final int MOTOROLA = 17;
+    public static final int NUBIA = 18;
+    public static final int SHARP = 19;
+    public static final int LEPHONE = 20;
+
+    public static final int HONOR = 21;
+
+    public static int getBrand() {
+        String brand = Build.BRAND;
+        if (brand.equalsIgnoreCase("HUAWEI")) {
+            return HuaWei;
+        } else if (brand.equalsIgnoreCase("Xiaomi")) {
+            return XiaoMi;
+        } else if (brand.equalsIgnoreCase("vivo")) {
+            return Vivo;
+        } else if (brand.equalsIgnoreCase("OPPO")) {
+            return Oppo;
+        } else if (brand.equalsIgnoreCase("Meizu")) {
+            return Meizu;
+        } else if (brand.equalsIgnoreCase("samsung")) {
+            return Samsung;
+        } else if (brand.equalsIgnoreCase("smartisan")) {
+            return Smartisan;
+        } else if (brand.equalsIgnoreCase("Lenovo")) {
+            return Lenovo;
+        } else if (brand.equalsIgnoreCase("ZTE")) {
+            return ZTE;
+        } else if (brand.equalsIgnoreCase("OnePlus")) {
+            return OnePlus;
+        } else if (brand.equalsIgnoreCase("Google")) {
+            return Google;
+        } else if (brand.equalsIgnoreCase("Sony")) {
+            return Sony;
+        } else if (brand.equalsIgnoreCase("LG")) {
+            return LG;
+        } else if (brand.equalsIgnoreCase("HTC")) {
+            return HTC;
+        } else if (brand.equalsIgnoreCase("ASUS")) {
+            return ASUS;
+        } else if (brand.equalsIgnoreCase("GIONEE")) {
+            return GIONEE;
+        } else if (brand.equalsIgnoreCase("MOTOROLA")) {
+            return MOTOROLA;
+        } else if (brand.equalsIgnoreCase("NUBIA")) {
+            return NUBIA;
+        } else if (brand.equalsIgnoreCase("SHARP")) {
+            return SHARP;
+        } else if (brand.equalsIgnoreCase("LETV")) {
+            return LEPHONE;
+        } else if (brand.equalsIgnoreCase("HONOR")) {
+            return HONOR;
+        } else {
+            return 0;
+        }
+    }
+}

+ 8 - 0
app/src/main/java/com/datarecovery/master/utils/FileUtil.java

@@ -3,6 +3,7 @@ package com.datarecovery.master.utils;
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.graphics.drawable.Drawable;
+import android.net.Uri;
 import android.text.TextUtils;
 import android.text.format.Formatter;
 
@@ -21,6 +22,13 @@ public class FileUtil {
 
     }
 
+    public static String getFilePath(String[] pathSegments) {
+        if (pathSegments == null) {
+            return null;
+        }
+        return String.join("%2F", pathSegments);
+    }
+
     public static String formatShortBytes(long bytes) {
         Context context = ContextUtil.getContext();
         return Formatter.formatShortFileSize(context, bytes);

+ 5 - 0
app/src/main/java/com/datarecovery/master/utils/FilesSearch.java

@@ -39,6 +39,11 @@ public class FilesSearch {
                     try {
                         CancellationSignal cancellationSignal = XFileSearch.searchExternalStorageAsync(context, new XFileSearch.FileFilter() {
                             @Override
+                            public List<String[]> getScanPaths() {
+                                return null;
+                            }
+
+                            @Override
                             public boolean acceptFile(XFile file) {
                                 return isAcceptFile(file);
                             }

+ 15 - 3
app/src/main/java/com/datarecovery/master/utils/ImageDeepDetector.java

@@ -69,8 +69,20 @@ public class ImageDeepDetector {
         return Flowable.create((FlowableOnSubscribe<XFile>) emitter -> {
                     try {
                         CancellationSignal cancellationSignal = XFileSearch.searchExternalStorageAsync(context,
+                                new XFileSearch.FilePreScanDirectory() {
+                                    @Override
+                                    public List<String> preScanPaths() {
+
+                                        return null;
+                                    }
+                                },
                                 new XFileSearch.FileFilter() {
                                     @Override
+                                    public List<String[]> getScanPaths() {
+                                        return null;
+                                    }
+
+                                    @Override
                                     public boolean acceptFile(XFile file) {
                                         return isAcceptFile(file);
                                     }
@@ -159,7 +171,7 @@ public class ImageDeepDetector {
         wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/);
     }
 
-    private static boolean isFilterDirectory(XFile file) {
+    public static boolean isFilterDirectory(XFile file) {
         try {
             String path = file.getPath();
             if (TextUtils.isEmpty(path)) {
@@ -281,7 +293,7 @@ public class ImageDeepDetector {
                 .onErrorComplete();
     }
 
-    private static boolean isAcceptDirectory(XFile file) {
+    public static boolean isAcceptDirectory(XFile file) {
         try {
             String path = file.getPath();
             if (isGalleryCacheDirectory(path)) {
@@ -293,7 +305,7 @@ public class ImageDeepDetector {
         return false;
     }
 
-    private static boolean isAcceptFile(XFile file) {
+    public static boolean isAcceptFile(XFile file) {
         try {
             if (file.length() == 0) {
                 return false;

+ 85 - 0
app/src/main/java/com/datarecovery/master/utils/filedetect/DetectFile.java

@@ -0,0 +1,85 @@
+package com.datarecovery.master.utils.filedetect;
+
+
+import android.net.Uri;
+
+import com.datarecovery.master.utils.FileUtil;
+import com.datarecovery.master.utils.xfile.XFile;
+
+public class DetectFile {
+
+    private XFile xFile;
+    @FileType
+    private int fileType;
+    private String name;
+
+    private long size;
+    private String sizeDescribe;
+    private Uri uri;
+    private String path;
+    private long lastModified;
+
+    public DetectFile(XFile xFile, @FileType int fileType) {
+        this.xFile = xFile;
+        this.fileType = fileType;
+        try {
+            this.name = xFile.getName();
+        } catch (Exception ignore) {
+        }
+        try {
+            this.size = xFile.length();
+        } catch (Exception ignore) {
+        }
+        try {
+            this.uri = xFile.getUri();
+        } catch (Exception ignore) {
+        }
+        try {
+            this.path = xFile.getPath();
+        } catch (Exception ignore) {
+        }
+        try {
+            this.lastModified = xFile.lastModified();
+        } catch (Exception ignore) {
+        }
+        this.sizeDescribe = FileUtil.formatShortBytes(this.size);
+    }
+
+    @FileType
+    public int getFileType() {
+        return fileType;
+    }
+
+    public void setFileType(@FileType int fileType) {
+        this.fileType = fileType;
+    }
+
+
+    public XFile getxFile() {
+        return xFile;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public long getSize() {
+        return size;
+    }
+
+    public String getSizeDescribe() {
+        return sizeDescribe;
+    }
+
+    public Uri getUri() {
+        return uri;
+    }
+
+    public String getPath() {
+        return path;
+    }
+
+    public long getLastModified() {
+        return lastModified;
+    }
+}

+ 379 - 0
app/src/main/java/com/datarecovery/master/utils/filedetect/FileScanHelper.java

@@ -0,0 +1,379 @@
+package com.datarecovery.master.utils.filedetect;
+
+import static android.content.Context.POWER_SERVICE;
+
+import android.content.Context;
+import android.os.CancellationSignal;
+import android.os.PowerManager;
+import android.text.TextUtils;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+
+import com.datarecovery.master.utils.BrandUtil;
+import com.datarecovery.master.utils.xfile.XFile;
+import com.datarecovery.master.utils.xfile.XFileSearch;
+
+import org.reactivestreams.Publisher;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import atmob.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import atmob.reactivex.rxjava3.core.BackpressureStrategy;
+import atmob.reactivex.rxjava3.core.Flowable;
+import atmob.reactivex.rxjava3.core.FlowableOnSubscribe;
+import atmob.reactivex.rxjava3.functions.Function;
+
+/**
+ * 文件扫描帮助类
+ */
+public class FileScanHelper {
+
+    private FileScanHelper() {
+
+    }
+
+    private static final List<String[]> wxDirList = new ArrayList<>();
+    private static final List<String[]> galleryList = new ArrayList<>();
+    private static final List<String[]> qqList = new ArrayList<>();
+
+
+    static {
+        initWxDir();
+        initGalleryDir();
+        initQQDir();
+    }
+
+    private static void initQQDir() {
+        qqList.add(new String[]{"Android", "data", "com.tencent.mobileqq"});
+        qqList.add(new String[]{"Android", "data", "com.tencent.tim"});
+        qqList.add(new String[]{"Pictures", "QQ"});
+        qqList.add(new String[]{"Download", "QQ"});
+        qqList.add(new String[]{"Download", "QQMail"});
+        qqList.add(new String[]{"Download", "QQBrowser"});
+    }
+
+    private static void initGalleryDir() {
+        galleryList.add(new String[]{"DCIM"});
+        galleryList.add(new String[]{"Pictures"});
+        galleryList.add(new String[]{"Android", "data", "com.android.gallery3d"});
+        galleryList.add(new String[]{"Android", "data", "com.android.gallery"});
+        galleryList.addAll(getFactoryGalleryDir());
+    }
+
+    private static void initWxDir() {
+        wxDirList.add(new String[]{"Android", "data", "com.tencent.mm"});
+        wxDirList.add(new String[]{"DCIM", "WeixinWork"});
+        wxDirList.add(new String[]{"Pictures", "WeiXin"});
+        wxDirList.add(new String[]{"Download", "WeiXin"});
+    }
+
+    /**
+     * 需要能单扫某个文件夹(某个app)
+     */
+
+    @IntDef({RefreshScanType.GALLERY, RefreshScanType.WEIXIN, RefreshScanType.QQ})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface RefreshScanType {
+        int GALLERY = 1;
+        int WEIXIN = 2;
+        int QQ = 3;
+    }
+
+
+    public static Flowable<List<DetectFile>> refreshImageDetect(@NonNull Context context, @RefreshScanType int scanType) {
+        return Flowable.create((FlowableOnSubscribe<XFile>) emitter -> {
+                    try {
+                        CancellationSignal cancellationSignal = XFileSearch.searchExternalStorageAsync(context, new XFileSearch.FileFilter() {
+                            @Override
+                            public List<String[]> getScanPaths() {
+                                return getImageScanDirectory(scanType);
+                            }
+
+                            @Override
+                            public boolean acceptFile(XFile file) {
+                                return isAcceptImageFile(file);
+                            }
+
+                            @Override
+                            public boolean acceptDirectory(XFile file) {
+                                return isAcceptImageDirectory(file);
+                            }
+
+                            @Override
+                            public boolean filterDirectory(XFile file) {
+                                return isFilterImageDirectory(scanType, file);
+                            }
+                        }, new XFileSearch.FileSearchCallback() {
+                            @Override
+                            public void onStart() {
+
+                            }
+
+                            @Override
+                            public void onEachFile(XFile file) {
+                                emitter.onNext(file);
+                            }
+
+                            @Override
+                            public void onFinish() {
+                                emitter.onComplete();
+                            }
+                        });
+                        emitter.setCancellable(cancellationSignal::cancel);
+                    } catch (Exception e) {
+                        emitter.onError(e);
+                    }
+                }, BackpressureStrategy.BUFFER)
+                .filter(xFile -> xFile.getTag() != null)
+                .flatMap((Function<XFile, Publisher<DetectFile>>) xFile -> {
+                    int tag = (int) xFile.getTag();
+                    switch (tag) {
+                        case ImageType.IMG_MAGIC:
+                        case ImageType.IMAGE_SUFFIX:
+                            return Flowable.just(new DetectFile(xFile, FileType.IMAGE_OTHER));
+                        case ImageType.XIAOMI_GALLERY_CACHE:
+                            return Flowable.just(new DetectFile(xFile, FileType.IMAGE_GALLERY));
+                        case ImageType.WECHAT_CACHE:
+                            return ImageCacheUtil.detectWechatCache(context, xFile);
+                        case ImageType.GALLERY_CACHE:
+                            return ImageCacheUtil.detectGalleryCache(context, xFile);
+                        case ImageType.OPPO_GALLERY_CACHE:
+                            return ImageCacheUtil.detectOppoGalleryCache(context, xFile);
+                        case ImageType.VIVO_GALLERY_CACHE:
+                            return ImageCacheUtil.detectVivoGalleryCache(context, xFile);
+                        case ImageType.MEIZU_GALLERY_CACHE:
+                            return ImageCacheUtil.detectMeizuGalleryCache(context, xFile);
+                        case ImageType.HUAWEI_GALLERY_CACHE:
+                            return ImageCacheUtil.detectHuaweiGalleryCache(context, xFile);
+                        default:
+                            return Flowable.empty();
+                    }
+                })
+                .buffer(200, TimeUnit.MILLISECONDS)
+                .filter(imageFiles -> imageFiles != null && !imageFiles.isEmpty())
+                .observeOn(AndroidSchedulers.mainThread())
+                .doOnSubscribe(subscription -> {
+                    subscription.request(Long.MAX_VALUE);
+                    acquireWakeLock(context);
+                })
+                .doOnCancel(() -> releaseWakeLock(context))
+                .doOnTerminate(() -> releaseWakeLock(context));
+    }
+
+    private static boolean isAcceptImageFile(XFile file) {
+        try {
+            if (file.length() == 0) {
+                return false;
+            }
+        } catch (Exception ignore) {
+        }
+        try {
+            String name = file.getName();
+            if (ImageCacheUtil.isImageSuffix(name)) {
+                file.setTag(ImageType.IMAGE_SUFFIX);
+                return true;
+            }
+        } catch (Exception ignore) {
+        }
+        try {
+            String path = file.getPath();
+            int brand = BrandUtil.getBrand();
+            switch (brand) {
+                case BrandUtil.Oppo:
+                    if (ImageCacheUtil.isOppoGalleryCacheFile(path)) {
+                        file.setTag(ImageType.OPPO_GALLERY_CACHE);
+                    }
+                    return true;
+                case BrandUtil.Vivo:
+                    if (ImageCacheUtil.isVivoGalleryCacheFile(path)) {
+                        file.setTag(ImageType.VIVO_GALLERY_CACHE);
+                    }
+                    return true;
+                case BrandUtil.XiaoMi:
+                    if (ImageCacheUtil.isXiaomiGalleryCacheFile(path)) {
+                        file.setTag(ImageType.XIAOMI_GALLERY_CACHE);
+                    }
+                    return true;
+                case BrandUtil.Meizu:
+                    if (ImageCacheUtil.isMeizuGalleryCacheFile(path)) {
+                        file.setTag(ImageType.MEIZU_GALLERY_CACHE);
+                    }
+                    return true;
+                case BrandUtil.HuaWei:
+                    if (ImageCacheUtil.isHuaweiGalleryCacheFile(path)) {
+                        file.setTag(ImageType.HUAWEI_GALLERY_CACHE);
+                    }
+                    return true;
+            }
+        } catch (Exception ignore) {
+        }
+        if (ImageCacheUtil.hasImgMagic(file)) {
+            file.setTag(ImageType.IMG_MAGIC);
+            return true;
+        }
+        return false;
+    }
+
+
+    public static boolean isAcceptImageDirectory(XFile file) {
+        try {
+            String path = file.getPath();
+            if (isGalleryCacheDirectory(path)) {
+                file.setTag(ImageType.GALLERY_CACHE);
+                return true;
+            }
+        } catch (Exception ignore) {
+        }
+        return false;
+    }
+
+    private static boolean isGalleryCacheDirectory(String path) {
+        if (TextUtils.isEmpty(path)) {
+            return false;
+        }
+        return path.contains("com.android.gallery3d%2Fcache") ||
+                path.contains("com.android.gallery3d/cache");
+    }
+
+
+    private static boolean isFilterImageDirectory(@RefreshScanType int scanType, @NonNull XFile file) {
+        try {
+            String path = file.getPath();
+            if (TextUtils.isEmpty(path)) {
+                return true;
+            }
+            if (RefreshScanType.GALLERY == scanType) {
+                if (path.endsWith("Pictures/QQ") || path.endsWith("Pictures%2FQQ") ||
+                        path.endsWith("DCIM/WeixinWork") || path.endsWith("DCIM%2FWeixinWork") ||
+                        path.endsWith("Pictures/WeiXin") || path.endsWith("Pictures%2FWeiXin")
+                ) {
+                    return true;
+                }
+            }
+        } catch (Exception e) {
+
+        }
+        return false;
+    }
+
+    private static void releaseWakeLock(Context context) {
+        PowerManager powerManager = (PowerManager) context.getSystemService(POWER_SERVICE);
+        PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
+                "ImageDeepDetector::detect");
+        if (wakeLock.isHeld()) {
+            wakeLock.release();
+        }
+    }
+
+    private static void acquireWakeLock(Context context) {
+        PowerManager powerManager = (PowerManager) context.getSystemService(POWER_SERVICE);
+        PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
+                "ImageDeepDetector::detect");
+        wakeLock.acquire(10 * 60 * 1000L /*10 minutes*/);
+    }
+
+    private static List<String[]> getGallery() {
+        return galleryList;
+    }
+
+    private static @NonNull List<String[]> getFactoryGalleryDir() {
+        List<String[]> brandGallery = new ArrayList<>();
+        int brand = BrandUtil.getBrand();
+        switch (brand) {
+            case BrandUtil.HuaWei:
+                brandGallery.add(new String[]{"Android", "data", "com.huawei.photos"});
+                break;
+            case BrandUtil.XiaoMi:
+                brandGallery.add(new String[]{"Android", "data", "com.miui.gallery"});
+                break;
+            case BrandUtil.Vivo:
+                brandGallery.add(new String[]{"Android", "data", "com.vivo.gallery"});
+                break;
+            case BrandUtil.Oppo:
+                brandGallery.add(new String[]{"Android", "data", "com.oppo.gallery3d"});
+                break;
+            case BrandUtil.Meizu:
+                brandGallery.add(new String[]{"Android", "data", "com.meizu.media.gallery"});
+                break;
+            case BrandUtil.Samsung:
+                brandGallery.add(new String[]{"Android", "data", "com.sec.android.gallery3d"});
+                break;
+            case BrandUtil.Smartisan:
+                brandGallery.add(new String[]{"Android", "data", "com.smartisanos.gallery"});
+                break;
+            case BrandUtil.Lenovo:
+                brandGallery.add(new String[]{"Android", "data", "com.lenovo.scg"});
+                break;
+            case BrandUtil.ZTE:
+                brandGallery.add(new String[]{"Android", "data", "com.zte.gallery"});
+                break;
+            case BrandUtil.OnePlus:
+                brandGallery.add(new String[]{"Android", "data", "com.oneplus.gallery"});
+                break;
+            case BrandUtil.Google:
+                brandGallery.add(new String[]{"Android", "data", "com.google.android.apps.photos"});
+                break;
+            case BrandUtil.Sony:
+                brandGallery.add(new String[]{"Android", "data", "com.sonyericsson.album"});
+                break;
+            case BrandUtil.LG:
+                brandGallery.add(new String[]{"Android", "data", "com.lge.gallery3d"});
+                break;
+            case BrandUtil.HTC:
+                brandGallery.add(new String[]{"Android", "data", "com.htc.album"});
+                break;
+            case BrandUtil.ASUS:
+                brandGallery.add(new String[]{"Android", "data", "com.asus.gallery"});
+                break;
+            case BrandUtil.GIONEE:
+                brandGallery.add(new String[]{"Android", "data", "com.gionee.gallery"});
+                break;
+            case BrandUtil.MOTOROLA:
+                brandGallery.add(new String[]{"Android", "data", "com.motorola.gallery"});
+                break;
+            case BrandUtil.NUBIA:
+                brandGallery.add(new String[]{"Android", "data", "com.nubia.gallery"});
+                break;
+            case BrandUtil.SHARP:
+                brandGallery.add(new String[]{"Android", "data", "com.sharp.gallery"});
+                break;
+            case BrandUtil.LEPHONE:
+                brandGallery.add(new String[]{"Android", "data", "com.letv.android.gallery3d"});
+                break;
+            case BrandUtil.HONOR:
+                brandGallery.add(new String[]{"Android", "data", "com.huawei.himovie"});
+                break;
+        }
+        return brandGallery;
+    }
+
+    private static List<String[]> getWeiXin() {
+        return wxDirList;
+    }
+
+    private static List<String[]> getQQ() {
+        return qqList;
+    }
+
+    private static List<String[]> getImageScanDirectory(@RefreshScanType int scanType) {
+        switch (scanType) {
+            case RefreshScanType.GALLERY:
+                return getGallery();
+            case RefreshScanType.WEIXIN:
+                return getWeiXin();
+            case RefreshScanType.QQ:
+                return getQQ();
+        }
+        return null;
+    }
+
+    public static void searchExternalDirectory(Context context) {
+    }
+
+
+}

+ 32 - 0
app/src/main/java/com/datarecovery/master/utils/filedetect/FileType.java

@@ -0,0 +1,32 @@
+package com.datarecovery.master.utils.filedetect;
+
+import androidx.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@IntDef({
+        FileType.IMAGE_GALLERY,
+        FileType.IMAGE_WEIXIN,
+        FileType.IMAGE_QQ,
+        FileType.IMAGE_OTHER,
+        FileType.FILE_WORD,
+        FileType.FILE_EXCEL,
+        FileType.FILE_PPT,
+        FileType.FILE_PDF,
+        FileType.VIDEO,
+        FileType.AUDIO
+})
+@Retention(RetentionPolicy.SOURCE)
+@interface FileType {
+    int IMAGE_GALLERY = 1;
+    int IMAGE_WEIXIN = 2;
+    int IMAGE_QQ = 3;
+    int IMAGE_OTHER = 4;
+    int FILE_WORD = 5;
+    int FILE_EXCEL = 6;
+    int FILE_PPT = 7;
+    int FILE_PDF = 8;
+    int VIDEO = 9;
+    int AUDIO = 10;
+}

+ 981 - 0
app/src/main/java/com/datarecovery/master/utils/filedetect/ImageCacheUtil.java

@@ -0,0 +1,981 @@
+package com.datarecovery.master.utils.filedetect;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.os.Environment;
+import android.text.TextUtils;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+
+import com.atmob.common.crypto.CryptoUtils;
+import com.datarecovery.master.utils.ImageDeepDetector;
+import com.datarecovery.master.utils.xfile.XFile;
+import com.datarecovery.master.utils.xfile.XPathFile;
+
+import org.reactivestreams.Publisher;
+import org.reactivestreams.Subscriber;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.RandomAccessFile;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteOrder;
+import java.nio.MappedByteBuffer;
+import java.nio.channels.FileChannel;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.zip.Adler32;
+
+import atmob.reactivex.rxjava3.core.Flowable;
+import atmob.reactivex.rxjava3.schedulers.Schedulers;
+
+public class ImageCacheUtil {
+
+    public static boolean isImageSuffix(String name) {
+        if (TextUtils.isEmpty(name)) {
+            return false;
+        }
+        return name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png")
+                || name.endsWith(".gif") || name.endsWith(".bmp") || name.endsWith(".webp")
+                || name.endsWith(".tiff") || name.endsWith(".psd") || name.endsWith(".svg")
+                || name.endsWith(".raw") || name.endsWith(".heif") || name.endsWith(".indd");
+    }
+
+    public static boolean isHuaweiGalleryCacheFile(String path) {
+        if (TextUtils.isEmpty(path)) {
+            return false;
+        }
+        if (!path.contains("com.huawei.photos%2Ffiles%2Fthumbdb") &&
+                !path.contains("com.huawei.photos/files/thumbdb")) {
+            return false;
+        }
+        return path.endsWith("photoshare.db");
+    }
+
+    public static boolean isMeizuGalleryCacheFile(String path) {
+        if (TextUtils.isEmpty(path)) {
+            return false;
+        }
+        if (!path.contains("com.meizu.media.gallery%2Fcache") &&
+                !path.contains("com.meizu.media.gallery/cache")) {
+            return false;
+        }
+        return path.contains("bestPhotoCache") || path.contains("face_thumbnails")
+                || path.contains("uri_thumbnail_cache");
+    }
+
+    public static boolean isXiaomiGalleryCacheFile(String path) {
+        if (TextUtils.isEmpty(path)) {
+            return false;
+        }
+        if (!path.contains("com.miui.gallery%2Ffiles%2Fgallery_disk_cache") &&
+                !path.contains("com.miui.gallery/files/gallery_disk_cache")) {
+            return false;
+        }
+        return path.contains("full_size") || path.contains("small_size");
+    }
+
+    public static boolean isVivoGalleryCacheFile(String path) {
+        if (TextUtils.isEmpty(path)) {
+            return false;
+        }
+        if (!path.contains("com.vivo.gallery%2Fcache") &&
+                !path.contains("com.vivo.gallery/cache")) {
+            return false;
+        }
+        return path.contains("imgcache") || path.contains("trackthumbnail_cache");
+    }
+
+    public static boolean isOppoGalleryCacheFile(String path) {
+        if (TextUtils.isEmpty(path)) {
+            return false;
+        }
+        if (!path.contains("com.coloros.gallery3d%2Fcache") &&
+                !path.contains("com.coloros.gallery3d/cache")) {
+            return false;
+        }
+        return path.contains("imgcache") || path.contains("screennailcache")
+                || path.contains("tilecache");
+    }
+
+    public static boolean hasImgMagic(XFile file) {
+        try (InputStream inputStream = file.newInputStream()) {
+            byte[] bytes = new byte[8];
+            if (inputStream.read(bytes) != 8) {
+                return false;
+            }
+
+            if (bytes[0] == (byte) 0x89 && bytes[1] == (byte) 0x50 && bytes[2] == (byte) 0x4E && bytes[3] == (byte) 0x47
+                    && bytes[4] == (byte) 0x0D && bytes[5] == (byte) 0x0A && bytes[6] == (byte) 0x1A && bytes[7] == (byte) 0x0A) {
+                // png
+                return true;
+            }
+
+            boolean hasHeaderMagic = bytes[0] == (byte) 0xFF && bytes[1] == (byte) 0xD8 && bytes[2] == (byte) 0xFF; // jpg header
+            if (!hasHeaderMagic) {
+                return false;
+            }
+
+            long skip = inputStream.available() - 2;
+            if (inputStream.skip(skip) != skip) {
+                return false;
+            }
+            if (inputStream.read(bytes) != 2) {
+                return false;
+            }
+            return bytes[0] == (byte) 0xFF && bytes[1] == (byte) 0xD9;
+        } catch (Exception ignore) {
+        }
+        return false;
+    }
+
+    public static Flowable<DetectFile> detectOppoGalleryCache(Context context, XFile xFile) {
+        return new GenericImgCollectionDetector(context, xFile, FileType.IMAGE_GALLERY)
+                .subscribeOn(Schedulers.io())
+                .onErrorComplete();
+    }
+
+    public static Flowable<DetectFile> detectGalleryCache(@NonNull Context context, @NonNull XFile xFile) {
+        return new GalleryCacheDetector(context, xFile)
+                .subscribeOn(Schedulers.io())
+                .onErrorComplete();
+    }
+
+    public static Publisher<DetectFile> detectWechatCache(@NonNull Context context, @NonNull XFile xFile) {
+        return new WechatCacheDetector(context, xFile)
+                .subscribeOn(Schedulers.io())
+                .onErrorComplete();
+    }
+
+    public static Publisher<DetectFile> detectVivoGalleryCache(@NonNull Context context, @NonNull XFile xFile) {
+        return new GenericImgCollectionDetector(context, xFile, FileType.IMAGE_GALLERY)
+                .subscribeOn(Schedulers.io())
+                .onErrorComplete();
+    }
+
+    public static Publisher<DetectFile> detectMeizuGalleryCache(@NonNull Context context, @NonNull XFile xFile) {
+        return new GenericImgCollectionDetector(context, xFile, FileType.IMAGE_GALLERY)
+                .subscribeOn(Schedulers.io())
+                .onErrorComplete();
+    }
+
+    public static Publisher<DetectFile> detectHuaweiGalleryCache(@NonNull Context context, @NonNull XFile xFile) {
+        return new HuaweiGalleryCacheDetector(context, xFile)
+                .subscribeOn(Schedulers.io())
+                .onErrorComplete();
+    }
+
+    private static class HuaweiGalleryCacheDetector extends Flowable<DetectFile> {
+
+        private static final String CACHE_DOMAIN = "huawei_gallery_cache_detector";
+        private final Context context;
+        private final XFile dbFile;
+
+        public HuaweiGalleryCacheDetector(Context context, XFile dbFile) {
+            this.context = context;
+            this.dbFile = dbFile;
+        }
+
+        @Override
+        protected void subscribeActual(@NonNull Subscriber<? super DetectFile> subscriber) {
+            long lastModified;
+            try {
+                lastModified = dbFile.lastModified();
+            } catch (Exception e) {
+                subscriber.onError(e);
+                return;
+            }
+
+            if (checkDetectedCache(context, lastModified, subscriber)) {
+                subscriber.onComplete();
+                return;
+            } else {
+                clearDetectedCache(context, CACHE_DOMAIN);
+            }
+
+            File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+            detectedCacheDir = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+            if (!detectedCacheDir.exists()) {
+                detectedCacheDir.mkdirs();
+            }
+
+            File dbTempFile;
+            try {
+                dbTempFile = createDbTempFile(detectedCacheDir);
+            } catch (Exception e) {
+                subscriber.onError(e);
+                return;
+            }
+
+            try (SQLiteDatabase sqLiteDatabase = SQLiteDatabase.openDatabase(dbTempFile.getPath(), null, SQLiteDatabase.OPEN_READONLY);
+                 Cursor cursor = sqLiteDatabase.rawQuery("select * from general_kv", null)
+            ) {
+                int vIndex = cursor.getColumnIndex("v");
+                if (vIndex == -1) {
+                    subscriber.onComplete();
+                    return;
+                }
+                while (cursor.moveToNext()) {
+                    byte[] data = cursor.getBlob(vIndex);
+                    if (data == null || data.length == 0) {
+                        continue;
+                    }
+                    File cache = new File(detectedCacheDir, UUID.randomUUID().toString());
+                    if (cache.createNewFile() && bytes2File(data, cache)) {
+                        subscriber.onNext(new DetectFile(new XPathFile(context, cache), FileType.IMAGE_GALLERY));
+                    }
+                }
+                subscriber.onComplete();
+            } catch (Exception e) {
+                subscriber.onError(e);
+            } finally {
+                if (dbTempFile != null && dbTempFile.exists()) {
+                    dbTempFile.delete();
+                }
+            }
+        }
+
+        private File createDbTempFile(File detectedCacheDir) throws Exception {
+            File dbTempFile = new File(detectedCacheDir, "huawei_gallery_cache_detector.db");
+            if (dbTempFile.exists()) {
+                dbTempFile.delete();
+            }
+            try (InputStream inputStream = dbFile.newInputStream();
+                 OutputStream outputStream = new FileOutputStream(dbTempFile)
+            ) {
+                byte[] buffer = new byte[2048];
+                int read;
+                while ((read = inputStream.read(buffer)) != -1) {
+                    outputStream.write(buffer, 0, read);
+                }
+                outputStream.flush();
+                return dbTempFile;
+            }
+        }
+
+        private boolean checkDetectedCache(Context context, long lastModified, Subscriber<? super DetectFile> subscriber) {
+            File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+            File targetCaches = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+            File[] files = targetCaches.exists() && targetCaches.isDirectory() ? targetCaches.listFiles() : null;
+            if (files != null && files.length > 0) {
+                for (File file : files) {
+                    if (file.getName().endsWith(".db")) {
+                        continue;
+                    }
+                    subscriber.onNext(new DetectFile(new XPathFile(this.context, file), FileType.IMAGE_GALLERY));
+                }
+                return true;
+            }
+            return false;
+        }
+    }
+
+    private static class GenericImgCollectionDetector extends Flowable<DetectFile> {
+
+        private final int category;
+        private String CACHE_DOMAIN = "generic_img_collection_detector";
+        private final Context context;
+        private final XFile xFile;
+
+        public GenericImgCollectionDetector(Context context, XFile xFile, @FileType int category) {
+            this.context = context;
+            this.xFile = xFile;
+            this.category = category;
+        }
+
+        @Override
+        protected void subscribeActual(@NonNull Subscriber<? super DetectFile> subscriber) {
+            long lastModified;
+            try {
+                lastModified = xFile.lastModified();
+                CACHE_DOMAIN += xFile.getName();
+            } catch (Exception e) {
+                subscriber.onError(e);
+                return;
+            }
+
+            if (checkDetectedCache(context, lastModified, subscriber)) {
+                subscriber.onComplete();
+                return;
+            } else {
+                clearDetectedCache(context, CACHE_DOMAIN);
+            }
+
+            File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+            detectedCacheDir = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+            if (!detectedCacheDir.exists()) {
+                detectedCacheDir.mkdirs();
+            }
+
+            try (InputStream inputStream = xFile.newInputStream()) {
+                ArrayList<Byte> imageBytes = new ArrayList<>();
+                byte[] buffer = new byte[2048];
+                int read;
+                while ((read = inputStream.read(buffer)) != -1) {
+                    for (int i = 0; i < read; i++) {
+                        byte b = buffer[i];
+                        imageBytes.add(b);
+                        if (imageBytes.size() < 3) {
+                            continue;
+                        }
+                        if (imageBytes.size() == 3) {
+                            if (imageBytes.get(0) != (byte) 0xFF || imageBytes.get(1) != (byte) 0xD8 || imageBytes.get(2) != (byte) 0xFF) {
+                                imageBytes.remove(0);
+                            }
+                            continue;
+                        }
+                        if (i == read - 1 && inputStream.available() == 0) {
+                            if (imageBytes.get(imageBytes.size() - 2) == (byte) 0xFF && imageBytes.get(imageBytes.size() - 1) == (byte) 0xD9) {
+                                File cache = new File(detectedCacheDir, UUID.randomUUID().toString());
+                                if (cache.createNewFile() && bytes2File(imageBytes, cache)) {
+                                    subscriber.onNext(new DetectFile(new XPathFile(context, cache), category));
+                                }
+                            }
+                            imageBytes.clear();
+                        } else if (imageBytes.size() >= 5) {
+                            if (imageBytes.get(imageBytes.size() - 1) == (byte) 0xD9
+                                    && imageBytes.get(imageBytes.size() - 2) == (byte) 0xFF
+                            ) {
+                                File cache = new File(detectedCacheDir, UUID.randomUUID().toString());
+                                if (cache.createNewFile() && bytes2File(imageBytes, cache)) {
+                                    subscriber.onNext(new DetectFile(new XPathFile(context, cache), category));
+                                }
+                                imageBytes.clear();
+                            }
+                        }
+                    }
+                }
+                subscriber.onComplete();
+            } catch (Exception e) {
+                subscriber.onError(e);
+            }
+        }
+
+        private boolean checkDetectedCache(Context context, long lastModified, Subscriber<? super DetectFile> subscriber) {
+            File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+            File targetCaches = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+            File[] files = targetCaches.exists() && targetCaches.isDirectory() ? targetCaches.listFiles() : null;
+            if (files != null && files.length > 0) {
+                for (File file : files) {
+                    subscriber.onNext(new DetectFile(new XPathFile(this.context, file), category));
+                }
+                return true;
+            }
+            return false;
+        }
+    }
+
+    private static class GalleryCacheDetector extends Flowable<DetectFile> {
+
+        private static final String CACHE_DOMAIN = "gallery_cache_detector";
+        private static final int MAGIC_INDEX_FILE = 0xB3273030;
+        private static final int MAGIC_DATA_FILE = 0xBD248510;
+
+        // index header offset
+        private static final int IH_MAGIC = 0;
+        private static final int IH_MAX_ENTRIES = 4;
+        private static final int IH_MAX_BYTES = 8;
+        private static final int IH_ACTIVE_REGION = 12;
+        private static final int IH_ACTIVE_ENTRIES = 16;
+        private static final int IH_ACTIVE_BYTES = 20;
+        private static final int IH_CHECKSUM = 28;
+        private static final int INDEX_HEADER_SIZE = 32;
+
+        private static final int DATA_HEADER_SIZE = 4;
+
+        // blob header offset
+        private static final int BH_KEY = 0;
+        private static final int BH_CHECKSUM = 8;
+        private static final int BH_OFFSET = 12;
+        private static final int BH_LENGTH = 16;
+        private static final int BLOB_HEADER_SIZE = 20;
+        private final XFile galleryCacheDir;
+        private final byte[] indexHeader;
+        private final byte[] blobHeader;
+        private final Adler32 mAdler32 = new Adler32();
+        private final Context context;
+
+        private int mMaxEntries;
+        private int mMaxBytes;
+        private int mActiveRegion;
+        private int mActiveBytes;
+
+        private FileChannel mIndexChannel;
+        private MappedByteBuffer mIndexBuffer;
+        private RandomAccessFile mIndexFile;
+        private RandomAccessFile mDataFile0;
+        private RandomAccessFile mDataFile1;
+
+        private RandomAccessFile mActiveDataFile;
+        private int mActiveHashStart;
+        private File indexTemp;
+        private File data0Temp;
+        private File data1Temp;
+
+        public GalleryCacheDetector(Context context, XFile galleryCacheDir) {
+            this.context = context;
+            this.galleryCacheDir = galleryCacheDir;
+            this.indexHeader = new byte[INDEX_HEADER_SIZE];
+            this.blobHeader = new byte[BLOB_HEADER_SIZE];
+        }
+
+        @Override
+        protected void subscribeActual(@NonNull Subscriber<? super DetectFile> subscriber) {
+            XFile[] xFiles;
+            try {
+                xFiles = galleryCacheDir.listFiles();
+            } catch (Exception e) {
+                subscriber.onError(e);
+                return;
+            }
+            if (xFiles == null || xFiles.length == 0) {
+                subscriber.onComplete();
+                return;
+            }
+            XFile indexFile = null;
+            XFile dataFile0 = null;
+            XFile dataFile1 = null;
+            for (XFile xFile : xFiles) {
+                try {
+                    String name = xFile.getName();
+                    if (name.endsWith(".idx")) {
+                        indexFile = xFile;
+                    } else if (name.endsWith(".0")) {
+                        dataFile0 = xFile;
+                    } else if (name.endsWith(".1")) {
+                        dataFile1 = xFile;
+                    }
+                } catch (Exception e) {
+                    subscriber.onError(e);
+                }
+            }
+            if (indexFile == null || dataFile0 == null || dataFile1 == null) {
+                subscriber.onComplete();
+                return;
+            }
+            doDetect(indexFile, dataFile0, dataFile1, subscriber);
+        }
+
+        private void doDetect(XFile indexFile, XFile dataFile0, XFile dataFile1, Subscriber<? super DetectFile> subscriber) {
+            try {
+                long lastModified;
+                try {
+                    lastModified = indexFile.lastModified();
+                } catch (Exception e) {
+                    subscriber.onError(e);
+                    return;
+                }
+
+                if (checkDetectedCache(context, lastModified, subscriber)) {
+                    subscriber.onComplete();
+                    return;
+                } else {
+                    clearDetectedCache(context, CACHE_DOMAIN);
+                }
+
+                File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+                detectedCacheDir = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+                if (!detectedCacheDir.exists()) {
+                    detectedCacheDir.mkdirs();
+                }
+
+                loadIndex(indexFile, dataFile0, dataFile1);
+                for (int i = 0; i < mMaxEntries; i++) {
+                    int offset = mActiveHashStart + i * 12;
+                    long candidateKey = mIndexBuffer.getLong(offset);
+                    try {
+                        GalleryCacheDetector.LookupRequest lookupRequest = new GalleryCacheDetector.LookupRequest(candidateKey);
+                        if (!lookup(lookupRequest)) {
+                            continue;
+                        }
+                        byte[] lookup = lookupRequest.buffer;
+                        if (lookup == null) {
+                            continue;
+                        }
+                        byte[] cropData = cropLookup(lookup);
+                        if (cropData == null) {
+                            continue;
+                        }
+                        File cache = new File(detectedCacheDir, UUID.randomUUID().toString());
+                        if (cache.createNewFile() && bytes2File(cropData, cache)) {
+                            subscriber.onNext(new DetectFile(new XPathFile(context, cache), FileType.IMAGE_GALLERY));
+                        }
+                    } catch (Exception ignore) {
+                    }
+                }
+                subscriber.onComplete();
+            } catch (Exception e) {
+                subscriber.onError(e);
+            } finally {
+                closeAll();
+                deleteTempFiles();
+            }
+        }
+
+        private boolean checkDetectedCache(Context context, long lastModified, Subscriber<? super DetectFile> subscriber) {
+            File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+            File targetCaches = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+            File[] files = targetCaches.exists() && targetCaches.isDirectory() ? targetCaches.listFiles() : null;
+            if (files != null && files.length > 0) {
+                for (File file : files) {
+                    subscriber.onNext(new DetectFile(new XPathFile(this.context, file), FileType.IMAGE_GALLERY));
+                }
+                return true;
+            }
+            return false;
+        }
+
+        private byte[] cropLookup(byte[] lookup) {
+            for (int i = 0; i < lookup.length; i++) {
+                if (lookup[i] == (byte) 0xFF && i + 1 < lookup.length && lookup[i + 1] == (byte) 0xD8) {
+                    return crop(lookup, i);
+                }
+            }
+            return null;
+        }
+
+        private byte[] crop(byte[] lookup, int i) {
+            byte[] crop = new byte[lookup.length - i];
+            System.arraycopy(lookup, i, crop, 0, crop.length);
+            return crop;
+        }
+
+        private void loadIndex(XFile indexFile, XFile dataFile0, XFile dataFile1) throws Exception {
+            checkFileValid(indexFile, dataFile0, dataFile1);
+
+            try (InputStream idxIs = indexFile.newInputStream();
+                 InputStream data0Is = dataFile0.newInputStream();
+                 InputStream data1Is = dataFile1.newInputStream()
+            ) {
+                indexTemp = createTempFile("index.temp", idxIs);
+                mIndexFile = new RandomAccessFile(indexTemp, "rw");
+
+                data0Temp = createTempFile("data0.temp", data0Is);
+                mDataFile0 = new RandomAccessFile(data0Temp, "rw");
+
+                data1Temp = createTempFile("data1.temp", data1Is);
+                mDataFile1 = new RandomAccessFile(data1Temp, "rw");
+
+                // Map index file to memory
+                mIndexChannel = mIndexFile.getChannel();
+                mIndexBuffer = mIndexChannel.map(FileChannel.MapMode.READ_WRITE,
+                        0, mIndexFile.length());
+                mIndexBuffer.order(ByteOrder.LITTLE_ENDIAN);
+
+                setActiveVariables();
+            }
+        }
+
+        private void setActiveVariables() throws Exception {
+            mActiveDataFile = (mActiveRegion == 0) ? mDataFile0 : mDataFile1;
+            mActiveDataFile.setLength(mActiveBytes);
+            mActiveDataFile.seek(mActiveBytes);
+
+            mActiveHashStart = INDEX_HEADER_SIZE;
+
+            if (mActiveRegion != 0) {
+                mActiveHashStart += mMaxEntries * 12;
+            }
+        }
+
+        private void checkFileValid(XFile indexFile, XFile dataFile0, XFile dataFile1) throws Exception {
+            byte[] buf = indexHeader;
+            try (InputStream inputStream = indexFile.newInputStream()) {
+                if (inputStream.read(buf) != INDEX_HEADER_SIZE) {
+                    throw new Exception("cannot read header");
+                }
+                if (readInt(buf, IH_MAGIC) != MAGIC_INDEX_FILE) {
+                    throw new Exception("cannot read header magic");
+                }
+            }
+
+            mMaxEntries = readInt(buf, IH_MAX_ENTRIES);
+            mMaxBytes = readInt(buf, IH_MAX_BYTES);
+            mActiveRegion = readInt(buf, IH_ACTIVE_REGION);
+            int mActiveEntries = readInt(buf, IH_ACTIVE_ENTRIES);
+            mActiveBytes = readInt(buf, IH_ACTIVE_BYTES);
+
+            int sum = readInt(buf, IH_CHECKSUM);
+            if (checkSum(buf, 0, IH_CHECKSUM) != sum) {
+                throw new Exception("header checksum does not match");
+            }
+
+            // Sanity check
+            if (mMaxEntries <= 0) {
+                throw new Exception("invalid max entries");
+            }
+            if (mMaxBytes <= 0) {
+                throw new Exception("invalid max bytes");
+            }
+            if (mActiveRegion != 0 && mActiveRegion != 1) {
+                throw new Exception("invalid active region");
+            }
+            if (mActiveEntries < 0 || mActiveEntries > mMaxEntries) {
+                throw new Exception("invalid active entries");
+            }
+            if (mActiveBytes < DATA_HEADER_SIZE || mActiveBytes > mMaxBytes) {
+                throw new Exception("invalid active bytes");
+            }
+            if (indexFile.length() != INDEX_HEADER_SIZE + mMaxEntries * 12 * 2L) {
+                throw new Exception("invalid index file length");
+            }
+
+            // Make sure data file has magic
+            byte[] magic = new byte[4];
+            try (InputStream data0Is = dataFile0.newInputStream()) {
+                if (data0Is.read(magic) != 4) {
+                    throw new Exception("cannot read data file magic");
+                }
+            }
+            if (readInt(magic, 0) != MAGIC_DATA_FILE) {
+                throw new Exception("invalid data file magic");
+            }
+            try (InputStream data1Is = dataFile1.newInputStream()) {
+                if (data1Is.read(magic) != 4) {
+                    throw new Exception("cannot read data file magic");
+                }
+            }
+            if (readInt(magic, 0) != MAGIC_DATA_FILE) {
+                throw new Exception("invalid data file magic");
+            }
+        }
+
+        private File createTempFile(String fileName, InputStream inputStream) throws Exception {
+            File tempFile = new File(context.getCacheDir(), fileName);
+            if (tempFile.exists()) {
+                tempFile.delete();
+            }
+            if (!tempFile.createNewFile()) {
+                throw new Exception("cannot create temp file");
+            }
+            try (OutputStream outputStream = new FileOutputStream(tempFile)) {
+                copyStream(inputStream, outputStream);
+            }
+            return tempFile;
+        }
+
+        public boolean lookup(GalleryCacheDetector.LookupRequest req) throws IOException {
+            if (lookupInternal(req.key, mActiveHashStart)) {
+                return getBlob(mActiveDataFile, mFileOffset, req);
+            }
+            return false;
+        }
+
+        private int mFileOffset;
+
+        private boolean lookupInternal(long key, int hashStart) {
+            int slot = (int) (key % mMaxEntries);
+            if (slot < 0) slot += mMaxEntries;
+            int slotBegin = slot;
+            while (true) {
+                int offset = hashStart + slot * 12;
+                long candidateKey = mIndexBuffer.getLong(offset);
+                int candidateOffset = mIndexBuffer.getInt(offset + 8);
+                if (candidateOffset == 0) {
+                    return false;
+                } else if (candidateKey == key) {
+                    mFileOffset = candidateOffset;
+                    return true;
+                } else {
+                    if (++slot >= mMaxEntries) {
+                        slot = 0;
+                    }
+                    if (slot == slotBegin) {
+                        mIndexBuffer.putInt(hashStart + slot * 12 + 8, 0);
+                    }
+                }
+            }
+        }
+
+        private boolean getBlob(RandomAccessFile file, int offset,
+                                GalleryCacheDetector.LookupRequest req) throws IOException {
+            byte[] header = blobHeader;
+            long oldPosition = file.getFilePointer();
+            try {
+                file.seek(offset);
+                if (file.read(header) != BLOB_HEADER_SIZE) {
+                    return false;
+                }
+                long blobKey = readLong(header, BH_KEY);
+                if (blobKey == 0) {
+                    return false; // This entry has been cleared.
+                }
+                if (blobKey != req.key) {
+                    return false;
+                }
+                int sum = readInt(header, BH_CHECKSUM);
+                int blobOffset = readInt(header, BH_OFFSET);
+                if (blobOffset != offset) {
+                    return false;
+                }
+                int length = readInt(header, BH_LENGTH);
+                if (length < 0 || length > mMaxBytes - offset - BLOB_HEADER_SIZE) {
+                    return false;
+                }
+                if (req.buffer == null || req.buffer.length < length) {
+                    req.buffer = new byte[length];
+                }
+
+                byte[] blob = req.buffer;
+                req.length = length;
+
+                if (file.read(blob, 0, length) != length) {
+                    return false;
+                }
+                return checkSum(blob, 0, length) == sum;
+            } catch (Throwable t) {
+                return false;
+            } finally {
+                file.seek(oldPosition);
+            }
+        }
+
+        static int readInt(byte[] buf, int offset) {
+            return (buf[offset] & 0xff)
+                    | ((buf[offset + 1] & 0xff) << 8)
+                    | ((buf[offset + 2] & 0xff) << 16)
+                    | ((buf[offset + 3] & 0xff) << 24);
+        }
+
+        static long readLong(byte[] buf, int offset) {
+            long result = buf[offset + 7] & 0xff;
+            for (int i = 6; i >= 0; i--) {
+                result = (result << 8) | (buf[offset + i] & 0xff);
+            }
+            return result;
+        }
+
+        int checkSum(byte[] data, int offset, int nbytes) {
+            mAdler32.reset();
+            mAdler32.update(data, offset, nbytes);
+            return (int) mAdler32.getValue();
+        }
+
+        private void copyStream(InputStream is, OutputStream os) throws Exception {
+            byte[] buf = new byte[2048];
+            int n;
+            while ((n = is.read(buf)) > 0) {
+                os.write(buf, 0, n);
+            }
+            os.flush();
+        }
+
+        private void closeAll() {
+            closeSilently(mIndexChannel);
+            closeSilently(mIndexFile);
+            closeSilently(mDataFile0);
+            closeSilently(mDataFile1);
+        }
+
+        private void closeSilently(Closeable c) {
+            if (c == null) return;
+            try {
+                c.close();
+            } catch (Throwable t) {
+                // do nothing
+            }
+        }
+
+        private void deleteTempFiles() {
+            deleteSilently(indexTemp);
+            deleteSilently(data0Temp);
+            deleteSilently(data1Temp);
+        }
+
+        private void deleteSilently(File tempFile) {
+            if (tempFile == null) {
+                return;
+            }
+            try {
+                if (tempFile.exists()) {
+                    tempFile.delete();
+                }
+            } catch (Throwable t) {
+                // do nothing
+            }
+        }
+
+        public static class LookupRequest {
+            public long key;        // input: the key to find
+            public byte[] buffer;   // input/output: the buffer to store the blob
+            public int length;      // output: the length of the blob
+
+            public LookupRequest(long key) {
+                this.key = key;
+            }
+        }
+    }
+
+    private static class WechatCacheDetector extends Flowable<DetectFile> {
+        private static final String CACHE_DOMAIN = "wechat_cache_detector";
+        private final XFile xFile;
+        private final Context context;
+
+        public WechatCacheDetector(Context context, XFile xFile) {
+            this.context = context;
+            this.xFile = xFile;
+        }
+
+        @Override
+        protected void subscribeActual(@NonNull Subscriber<? super DetectFile> subscriber) {
+            long lastModified;
+            try {
+                lastModified = xFile.lastModified();
+            } catch (Exception e) {
+                subscriber.onError(e);
+                return;
+            }
+
+            if (checkDetectedCache(context, lastModified, subscriber)) {
+                subscriber.onComplete();
+                return;
+            } else {
+                clearDetectedCache(context, CACHE_DOMAIN);
+            }
+
+            File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+            detectedCacheDir = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+            if (!detectedCacheDir.exists()) {
+                detectedCacheDir.mkdirs();
+            }
+
+            try (InputStream inputStream = xFile.newInputStream()) {
+                ArrayList<Byte> imageBytes = new ArrayList<>();
+                byte[] buffer = new byte[2048];
+                int read;
+                while ((read = inputStream.read(buffer)) != -1) {
+                    for (int i = 0; i < read; i++) {
+                        byte b = buffer[i];
+                        imageBytes.add(b);
+                        if (imageBytes.size() < 2) {
+                            continue;
+                        }
+                        if (imageBytes.size() == 2) {
+                            if (imageBytes.get(0) != (byte) 0xFF || imageBytes.get(1) != (byte) 0xD8) {
+                                imageBytes.remove(0);
+                            }
+                            continue;
+                        }
+                        if (i == read - 1 && inputStream.available() == 0) {
+                            if (imageBytes.get(imageBytes.size() - 2) == (byte) 0xFF && imageBytes.get(imageBytes.size() - 1) == (byte) 0xD9) {
+                                File cache = new File(detectedCacheDir, UUID.randomUUID().toString());
+                                if (cache.createNewFile() && bytes2File(imageBytes, cache)) {
+                                    subscriber.onNext(new DetectFile(new XPathFile(context, cache), FileType.IMAGE_WEIXIN));
+                                }
+                            }
+                            imageBytes.clear();
+                        } else if (imageBytes.size() >= 6) {
+                            if (imageBytes.get(imageBytes.size() - 1) == (byte) 0xD8
+                                    && imageBytes.get(imageBytes.size() - 2) == (byte) 0xFF
+                                    && imageBytes.get(imageBytes.size() - 3) == (byte) 0xD9
+                                    && imageBytes.get(imageBytes.size() - 4) == (byte) 0xFF
+                            ) {
+                                imageBytes.remove(imageBytes.size() - 1);
+                                imageBytes.remove(imageBytes.size() - 1);
+                                File cache = new File(detectedCacheDir, UUID.randomUUID().toString());
+                                if (cache.createNewFile() && bytes2File(imageBytes, cache)) {
+                                    subscriber.onNext(new DetectFile(new XPathFile(context, cache), FileType.IMAGE_WEIXIN));
+                                }
+                                imageBytes.clear();
+                                imageBytes.add((byte) 0xFF);
+                                imageBytes.add((byte) 0xD8);
+                            }
+                        }
+                    }
+                }
+                subscriber.onComplete();
+            } catch (Exception e) {
+                subscriber.onError(e);
+            }
+        }
+
+        private boolean checkDetectedCache(Context context, long lastModified, Subscriber<? super DetectFile> subscriber) {
+            File detectedCacheDir = getDetectedCacheDir(context, CACHE_DOMAIN);
+            File targetCaches = new File(detectedCacheDir, CryptoUtils.HASH.md5(String.valueOf(lastModified)));
+            File[] files = targetCaches.exists() && targetCaches.isDirectory() ? targetCaches.listFiles() : null;
+            if (files != null && files.length > 0) {
+                for (File file : files) {
+                    subscriber.onNext(new DetectFile(new XPathFile(this.context, file), FileType.IMAGE_WEIXIN));
+                }
+                return true;
+            }
+            return false;
+        }
+    }
+
+    private static void clearDetectedCache(Context context, String domain) {
+        File detectedCacheDir = getDetectedCacheDir(context, domain);
+        try {
+            clearDir(detectedCacheDir);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+    private static boolean bytes2File(byte[] bytes, File file) {
+        try (FileOutputStream fileOutputStream = new FileOutputStream(file)) {
+            fileOutputStream.write(bytes);
+            fileOutputStream.flush();
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return false;
+    }
+
+    private static boolean bytes2File(List<Byte> bytes, File file) {
+        try (FileOutputStream fileOutputStream = new FileOutputStream(file)) {
+            for (Byte aByte : bytes) {
+                fileOutputStream.write(aByte);
+            }
+            fileOutputStream.flush();
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return false;
+    }
+
+    private static void clearDir(File dir) {
+        if (dir == null || !dir.exists()) {
+            return;
+        }
+        File[] files = dir.listFiles();
+        if (files == null || files.length == 0) {
+            dir.delete();
+            return;
+        }
+        for (File file : files) {
+            if (file.isDirectory()) {
+                clearDir(file);
+            } else {
+                file.delete();
+            }
+        }
+        dir.delete();
+    }
+
+    private static File getDetectedCacheDir(Context context, String domain) {
+        File cacheDir;
+        if (Objects.equals(Environment.getExternalStorageState(), Environment.MEDIA_MOUNTED)
+                && Environment.getExternalStorageDirectory().canWrite()) {
+            cacheDir = context.getExternalCacheDir();
+        } else {
+            cacheDir = context.getCacheDir();
+        }
+        File detectedCacheDir = new File(cacheDir, CryptoUtils.HASH.md5(domain));
+        if (!detectedCacheDir.exists()) {
+            detectedCacheDir.mkdirs();
+        }
+        return detectedCacheDir;
+    }
+}

+ 31 - 0
app/src/main/java/com/datarecovery/master/utils/filedetect/ImageType.java

@@ -0,0 +1,31 @@
+package com.datarecovery.master.utils.filedetect;
+
+
+import androidx.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@IntDef({
+        ImageType.IMAGE_SUFFIX,
+        ImageType.WECHAT_CACHE,
+        ImageType.GALLERY_CACHE,
+        ImageType.IMG_MAGIC,
+        ImageType.OPPO_GALLERY_CACHE,
+        ImageType.VIVO_GALLERY_CACHE,
+        ImageType.XIAOMI_GALLERY_CACHE,
+        ImageType.MEIZU_GALLERY_CACHE,
+        ImageType.HUAWEI_GALLERY_CACHE
+})
+@Retention(RetentionPolicy.SOURCE)
+public @interface ImageType {
+    int IMAGE_SUFFIX = 1;
+    int WECHAT_CACHE = 2;
+    int GALLERY_CACHE = 3;
+    int IMG_MAGIC = 4;
+    int OPPO_GALLERY_CACHE = 5;
+    int VIVO_GALLERY_CACHE = 6;
+    int XIAOMI_GALLERY_CACHE = 7;
+    int MEIZU_GALLERY_CACHE = 8;
+    int HUAWEI_GALLERY_CACHE = 9;
+}

+ 160 - 71
app/src/main/java/com/datarecovery/master/utils/xfile/XFileSearch.java

@@ -11,6 +11,7 @@ import androidx.annotation.RequiresApi;
 import androidx.annotation.WorkerThread;
 
 import com.atmob.common.thread.ThreadPoolUtil;
+import com.datarecovery.master.utils.FileUtil;
 
 import java.io.File;
 import java.util.ArrayList;
@@ -56,7 +57,19 @@ public class XFileSearch {
         CancellationSignal cancellationSignal = new CancellationSignal();
         ThreadPoolUtil.getInstance().execute(() -> {
             if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
-                searchExternalStorageOldAsync(context, fileFilter, callback, cancellationSignal);
+                searchExternalStorageOldAsync(context, null, fileFilter, callback, cancellationSignal);
+            } else {
+                searchExternalStorageNewAsync(context, fileFilter, callback, cancellationSignal);
+            }
+        });
+        return cancellationSignal;
+    }
+
+    public static CancellationSignal searchExternalStorageAsync(Context context, FilePreScanDirectory filePreScanDirectory, FileFilter fileFilter, FileSearchCallback callback) {
+        CancellationSignal cancellationSignal = new CancellationSignal();
+        ThreadPoolUtil.getInstance().execute(() -> {
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+                searchExternalStorageOldAsync(context, filePreScanDirectory, fileFilter, callback, cancellationSignal);
             } else {
                 searchExternalStorageNewAsync(context, fileFilter, callback, cancellationSignal);
             }
@@ -70,7 +83,7 @@ public class XFileSearch {
         final int maxThreadCount = 5;
         Lock lock = new ReentrantLock();
         Condition condition = lock.newCondition();
-        List<XFile> startFiles = getExternalStorageFilesNew(context);
+        List<XFile> startFiles = getExternalStorageFilesNew(context, fileFilter);
         ArrayList<XFile> tasks = new ArrayList<>();
         for (XFile startFile : startFiles) {
             try {
@@ -144,20 +157,20 @@ public class XFileSearch {
         }
     }
 
-    private static void searchExternalStorageOldAsync(Context context, FileFilter fileFilter,
+    private static void searchExternalStorageOldAsync(Context context, FilePreScanDirectory filePreScanDirectory, FileFilter fileFilter,
                                                       FileSearchCallback callback,
                                                       CancellationSignal cancellationSignal) {
-        List<XFile> startFiles = getExternalStorageFilesOld(context);
+        List<XFile> startFiles = getExternalStorageFilesOld(context, fileFilter, filePreScanDirectory);
         searchInternalAsync(startFiles, fileFilter, callback, cancellationSignal);
     }
 
     private static List<XFile> searchExternalStorageNew(Context context, FileFilter fileFilter) {
-        List<XFile> startFiles = getExternalStorageFilesNew(context);
+        List<XFile> startFiles = getExternalStorageFilesNew(context, fileFilter);
         return searchInternal(startFiles, fileFilter);
     }
 
     private static List<XFile> searchExternalStorageOld(Context context, FileFilter fileFilter) {
-        List<XFile> startFiles = getExternalStorageFilesOld(context);
+        List<XFile> startFiles = getExternalStorageFilesOld(context, fileFilter, null);
         return searchInternal(startFiles, fileFilter);
     }
 
@@ -208,37 +221,65 @@ public class XFileSearch {
     }
 
     @NonNull
-    private static List<XFile> getExternalStorageFilesNew(Context context) {
+    private static List<XFile> getExternalStorageFilesNew(Context context, FileFilter fileFilter) {
         List<StorageVolume> readableStorageVolumes = XStorageManager.getReadableStorageVolumes(context);
         List<XFile> startFiles = new ArrayList<>();
         for (StorageVolume storageVolume : readableStorageVolumes) {
             Uri treeUri = StorageVolumeUtil.getTreeUri(storageVolume);
-            XSAFFile xsafFile = new XSAFFile(context, treeUri, Arrays.asList("Android", "data"));
-            try {
-                if (xsafFile.exists()) {
-                    startFiles.add(xsafFile);
+            if (fileFilter != null && fileFilter.getScanPaths() != null && !fileFilter.getScanPaths().isEmpty()) {
+                File directory = StorageVolumeUtil.getDirectory(storageVolume);
+                for (String[] scanPath : fileFilter.getScanPaths()) {
+                    String filePath = FileUtil.getFilePath(scanPath);
+                    if (filePath.contains("Android%2Fdata")) {
+                        XSAFFile xsafFile = new XSAFFile(context, treeUri, scanPath);
+                        try {
+                            if (xsafFile.exists()) {
+                                startFiles.add(xsafFile);
+                            }
+                        } catch (Exception e) {
+                            e.printStackTrace();
+                        }
+                    } else {
+                        if (directory != null) {
+                            XPathFile xPathFile = new XPathFile(context, directory, filePath);
+                            try {
+                                if (xPathFile.exists()) {
+                                    startFiles.add(xPathFile);
+                                }
+                            } catch (Exception e) {
+                                e.printStackTrace();
+                            }
+                        }
+                    }
                 }
-            } catch (Exception e) {
-                e.printStackTrace();
-            }
-
-            File directory = StorageVolumeUtil.getDirectory(storageVolume);
-            if (directory != null) {
-                XPathFile xPathFile = new XPathFile(context, directory);
+            } else {
+                XSAFFile xsafFile = new XSAFFile(context, treeUri, Arrays.asList("Android", "data"));
                 try {
-                    if (xPathFile.exists()) {
-                        startFiles.add(xPathFile);
+                    if (xsafFile.exists()) {
+                        startFiles.add(xsafFile);
                     }
                 } catch (Exception e) {
                     e.printStackTrace();
                 }
+
+                File directory = StorageVolumeUtil.getDirectory(storageVolume);
+                if (directory != null) {
+                    XPathFile xPathFile = new XPathFile(context, directory);
+                    try {
+                        if (xPathFile.exists()) {
+                            startFiles.add(xPathFile);
+                        }
+                    } catch (Exception e) {
+                        e.printStackTrace();
+                    }
+                }
             }
         }
         return startFiles;
     }
 
     @NonNull
-    private static List<XFile> getExternalStorageFilesOld(Context context) {
+    private static List<XFile> getExternalStorageFilesOld(Context context, FileFilter fileFilter, FilePreScanDirectory filePreScanDirectory) {
         List<StorageVolume> readableStorageVolumes = XStorageManager.getReadableStorageVolumes(context);
         List<XFile> startFiles = new ArrayList<>();
         for (StorageVolume storageVolume : readableStorageVolumes) {
@@ -246,13 +287,58 @@ public class XFileSearch {
             if (directory == null) {
                 continue;
             }
-            XFile androidData = new XPathFile(context, directory);
-            try {
-                if (androidData.exists()) {
-                    startFiles.add(androidData);
+            if (fileFilter != null && fileFilter.getScanPaths() != null && !fileFilter.getScanPaths().isEmpty()) {
+                for (String[] searchPath : fileFilter.getScanPaths()) {
+                    try {
+                        File file = new File(directory, FileUtil.getFilePath(searchPath));
+                        if (file.exists()) {
+                            startFiles.add(new XPathFile(context, file));
+                        }
+                    } catch (Exception ignore) {
+
+                    }
+                }
+            } else if (filePreScanDirectory != null && filePreScanDirectory.preScanPaths() != null && !filePreScanDirectory.preScanPaths().isEmpty()) {
+                // 预扫描目录
+                List<XFile> preFiles = new ArrayList<>();
+                for (String searchPath : filePreScanDirectory.preScanPaths()) {
+                    try {
+                        File file = new File(directory, searchPath);
+                        if (file.exists()) {
+                            preFiles.add(new XPathFile(context, file));
+                        }
+                    } catch (Exception ignore) {
+
+                    }
+                }
+                XFile androidData = new XPathFile(context, directory);
+                File androidDataFile = new File(directory, "Android%2Fdata");
+                String androidDataFilePath = androidDataFile.getPath();
+                List<XFile> rootFiles = new ArrayList<>();
+                File[] files = androidDataFile.listFiles();
+                if (files != null) {
+                    for (File file : files) {
+                        if (!androidDataFilePath.contains(file.getPath())) {
+                            rootFiles.add(new XPathFile(context, file));
+                        }
+                    }
+                }
+                try {
+                    if (androidDataFile.exists()) {
+
+                    }
+                } catch (Exception ignore) {
+
+                }
+            } else {
+                XFile androidData = new XPathFile(context, directory);
+                try {
+                    if (androidData.exists()) {
+                        startFiles.add(androidData);
+                    }
+                } catch (Exception e) {
+                    e.printStackTrace();
                 }
-            } catch (Exception e) {
-                e.printStackTrace();
             }
         }
         return startFiles;
@@ -321,6 +407,9 @@ public class XFileSearch {
      */
     private static void dfsSearchAsync(List<XFile> startFiles, FileFilter fileFilter,
                                        FileSearchCallback callback, CancellationSignal cancellationSignal) {
+        if (fileFilter == null) {
+            throw new RuntimeException("FileFilter is null");
+        }
         Stack<XFile> fileStock = new Stack<>();
         fileStock.addAll(startFiles);
         if (callback != null) {
@@ -335,33 +424,29 @@ public class XFileSearch {
                 continue;
             }
             try {
-                XFile[] files = file.listFiles();
-                if (files == null) {
-                    continue;
-                }
-                for (XFile childFile : files) {
-                    if (childFile == null) {
+                if (file.isDirectory()) {
+                    if (fileFilter.filterDirectory(file)) {
                         continue;
                     }
-                    if (childFile.isDirectory()) {
-                        if (fileFilter == null) {
-                            fileStock.push(childFile);
-                            continue;
+                    if (fileFilter.acceptDirectory(file)) {
+                        if (callback != null) {
+                            callback.onEachFile(file);
                         }
-                        if (fileFilter.filterDirectory(childFile)) {
+                        continue;
+                    }
+                    XFile[] files = file.listFiles();
+                    if (files == null) {
+                        continue;
+                    }
+                    for (XFile childFile : files) {
+                        if (childFile == null) {
                             continue;
                         }
-                        if (fileFilter.acceptDirectory(childFile)) {
-                            if (callback != null) {
-                                callback.onEachFile(childFile);
-                            }
-                        } else {
-                            fileStock.push(childFile);
-                        }
-                    } else if (fileFilter == null || fileFilter.acceptFile(childFile)) {
-                        if (callback != null) {
-                            callback.onEachFile(childFile);
-                        }
+                        fileStock.push(childFile);
+                    }
+                } else if (fileFilter.acceptFile(file)) {
+                    if (callback != null) {
+                        callback.onEachFile(file);
                     }
                 }
             } catch (Exception e) {
@@ -422,6 +507,9 @@ public class XFileSearch {
      */
     private static void bfsSearchAsync(List<XFile> startFiles, FileFilter fileFilter,
                                        FileSearchCallback callback, CancellationSignal cancellationSignal) {
+        if (fileFilter == null) {
+            throw new RuntimeException("FileFilter is null");
+        }
         Queue<XFile> fileQueue = new LinkedList<>(startFiles);
         if (callback != null) {
             callback.onStart();
@@ -435,33 +523,26 @@ public class XFileSearch {
                 continue;
             }
             try {
-                XFile[] files = file.listFiles();
-                if (files == null) {
-                    continue;
-                }
-                for (XFile childFile : files) {
-                    if (childFile == null) {
+                if (file.isDirectory()) {
+                    if (fileFilter.filterDirectory(file)) {
                         continue;
                     }
-                    if (childFile.isDirectory()) {
-                        if (fileFilter == null) {
-                            fileQueue.offer(childFile);
-                            continue;
+                    if (fileFilter.acceptDirectory(file)) {
+                        if (callback != null) {
+                            callback.onEachFile(file);
                         }
-                        if (fileFilter.filterDirectory(childFile)) {
+                        continue;
+                    }
+                    XFile[] files = file.listFiles();
+                    for (XFile childFile : files) {
+                        if (childFile == null) {
                             continue;
                         }
-                        if (fileFilter.acceptDirectory(childFile)) {
-                            if (callback != null) {
-                                callback.onEachFile(childFile);
-                            }
-                        } else {
-                            fileQueue.offer(childFile);
-                        }
-                    } else if (fileFilter == null || fileFilter.acceptFile(childFile)) {
-                        if (callback != null) {
-                            callback.onEachFile(childFile);
-                        }
+                        fileQueue.offer(childFile);
+                    }
+                } else if (fileFilter.acceptFile(file)) {
+                    if (callback != null) {
+                        callback.onEachFile(file);
                     }
                 }
             } catch (Exception e) {
@@ -473,7 +554,15 @@ public class XFileSearch {
         }
     }
 
+    public interface FilePreScanDirectory {
+        //仅一级目录以及Android/data目录
+        List<String> preScanPaths();
+    }
+
     public interface FileFilter {
+
+        List<String[]> getScanPaths();
+
         boolean acceptFile(XFile file);
 
         boolean acceptDirectory(XFile file);

+ 47 - 2
app/src/main/java/com/datarecovery/master/utils/xfile/XSAFFile.java

@@ -5,6 +5,8 @@ import android.database.Cursor;
 import android.net.Uri;
 import android.provider.DocumentsContract;
 
+import com.datarecovery.master.utils.FileUtil;
+
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.InputStream;
@@ -19,6 +21,47 @@ public class XSAFFile extends BaseXFile {
     private final Uri pathUri;
     private final String path;
 
+
+    public XSAFFile(Context context, Uri treeRoot, String[] segments) {
+        this.context = context;
+        Uri androidDataUri = null;
+        if (segments == null || segments.length == 0) {
+            this.pathUri = treeRoot;
+        } else {
+            StringBuilder stringBuilder = new StringBuilder(treeRoot.toString());
+            stringBuilder.append("%3A");
+            for (int i = 0; i < segments.length; i++) {
+                stringBuilder.append(Uri.encode(segments[i]));
+                if (i == 1) {
+                    androidDataUri = Uri.parse(stringBuilder.toString());
+                }
+                if (i != segments.length - 1) {
+                    stringBuilder.append("%2F");
+                }
+            }
+            this.pathUri = Uri.parse(stringBuilder.toString());
+        }
+        StringBuilder pathBuilder = new StringBuilder(File.separator);
+        for (String segment : treeRoot.getPathSegments()) {
+            pathBuilder.append(segment).append(File.separator);
+        }
+        if (segments != null && segments.length != 0) {
+            for (String segment : segments) {
+                pathBuilder.append(segment).append(File.separator);
+            }
+            pathBuilder.deleteCharAt(pathBuilder.length() - 1);
+        }
+        this.path = pathBuilder.toString();
+
+        String documentId;
+        if (DocumentsContract.isDocumentUri(context, pathUri)) {
+            documentId = DocumentsContract.getDocumentId(pathUri);
+        } else {
+            documentId = DocumentsContract.getTreeDocumentId(pathUri);
+        }
+        this.documentUri = DocumentsContract.buildDocumentUriUsingTree(androidDataUri, documentId);
+    }
+
     public XSAFFile(Context context, Uri treeRoot, List<String> segments) {
         this.context = context;
 
@@ -28,8 +71,10 @@ public class XSAFFile extends BaseXFile {
             StringBuilder stringBuilder = new StringBuilder(treeRoot.toString());
             stringBuilder.append("%3A");
             for (int i = 0; i < segments.size(); i++) {
-                stringBuilder.append(Uri.encode(segments.get(i)));
-                if (i != segments.size() - 1) {
+                if (i < 2) {
+                    stringBuilder.append(Uri.encode(segments.get(i)));
+                }
+                if (i != segments.size() - 1 && i < 1) {
                     stringBuilder.append("%2F");
                 }
             }