瀏覽代碼

[New]新增相册备份&恢复

zhipeng 8 月之前
父節點
當前提交
25ce1e4590

+ 8 - 2
app/src/main/java/com/datarecovery/master/module/imgrecover/ImageItemAdapter.java

@@ -8,11 +8,11 @@ import androidx.annotation.NonNull;
 import androidx.lifecycle.LifecycleOwner;
 import androidx.recyclerview.widget.RecyclerView;
 
-import com.atmob.common.logging.AtmobLog;
 import com.datarecovery.master.databinding.ItemDataImgBinding;
 import com.datarecovery.master.sdk.bugly.BuglyHelper;
 import com.datarecovery.master.utils.ImageDeepDetector;
 
+import java.util.Collections;
 import java.util.List;
 
 public class ImageItemAdapter extends RecyclerView.Adapter<ImageItemAdapter.ViewHolder> {
@@ -60,9 +60,15 @@ public class ImageItemAdapter extends RecyclerView.Adapter<ImageItemAdapter.View
     }
 
     public void submit(List<ImageDeepDetector.ImageFile> imageList) {
-        if (imageList == null) {
+        if (imageList == null || imageList.isEmpty()) {
             return;
         }
+        Collections.sort(imageList, (o1, o2) -> {
+            if (o1 == null || o2 == null) {
+                return 0;
+            }
+            return Long.compare(o1.getLastModified(), o2.getLastModified());
+        });
         int itemCount;
         if (list != null) {
             itemCount = imageList.size() - size;

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

@@ -4,6 +4,7 @@ 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.GalleryBackupManager;
 import com.datarecovery.master.utils.OrderReportHelper;
 import com.google.gson.Gson;
 
@@ -14,9 +15,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
 @HiltViewModel
 public class MainViewModel extends BaseViewModel {
 
-
     @Inject
     public MainViewModel(Gson gson, MemberRepository memberRepository, ConfigRepository configRepository, FileScanRepository fileScanRepository) {
         OrderReportHelper.init(gson, memberRepository);
+
+        GalleryBackupManager.getInstance().init();
     }
 }

+ 2 - 0
app/src/main/java/com/datarecovery/master/utils/FilePermissionHelper.java

@@ -97,6 +97,8 @@ public class FilePermissionHelper {
                                                 public void onPermissionGranted() {
                                                     requestManageDialog.dismiss();
                                                     observer.onSuccess(sdkInt);
+
+                                                    GalleryBackupManager.getInstance().onPermissionGranted();
                                                 }
 
                                                 @Override

+ 435 - 0
app/src/main/java/com/datarecovery/master/utils/GalleryBackupManager.java

@@ -0,0 +1,435 @@
+package com.datarecovery.master.utils;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.MediaStore;
+
+import com.atmob.common.crypto.CryptoUtils;
+import com.atmob.common.data.KVUtils;
+import com.atmob.common.logging.AtmobLog;
+import com.atmob.utils.android.ActivityManager;
+import com.atmob.utils.android.ContextUtil;
+import com.atmob.utils.thread.ThreadPool;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class GalleryBackupManager implements ActivityManager.ProcessLifecycleListener {
+
+    private static final String TAG = "GalleryBackupManager";
+    private static final long MAX_BITMAP_SIZE = 2 * 1024 * 1024; // 2 MB
+    private static final long MAX_BACKUP_SIZE = (long) (1.5 * 1024 * 1024 * 1024); //1.5 GB
+    private static final long MAX_SIZE_PER_IMAGE = 500 * 1024; // 500 KB
+    private static final String KEY_OLDEST_IMAGE_ADDED_DATE = TAG + "oldest_image_added_date";
+    private static final String BACKUP_DIR = "GalleryBackup";
+    private static final String RECOVERY_DIR = "GalleryRecovery";
+
+    private final AtomicBoolean isBackupInProgress = new AtomicBoolean(false);
+
+    private final ContentResolver contentResolver;
+    private final File rootDir;
+
+    private static class Holder {
+        private static final GalleryBackupManager INSTANCE = new GalleryBackupManager();
+    }
+
+    public static GalleryBackupManager getInstance() {
+        return Holder.INSTANCE;
+    }
+
+    private GalleryBackupManager() {
+        contentResolver = ContextUtil.getContext().getContentResolver();
+        rootDir = ContextUtil.getContext().getFilesDir();
+    }
+
+    public void init() {
+        AtmobLog.d(TAG, "Initializing GalleryBackupManager...");
+        ActivityManager.addProcessLifecycleListener(this);
+        startBackup();
+    }
+
+    public void onPermissionGranted() {
+        AtmobLog.d(TAG, "File permission granted, starting gallery backup...");
+        // Start backup immediately if permission is granted
+        startBackup();
+    }
+
+    public List<File> getRecoveryFiles() {
+        File recoveryDir = getRecoveryDir();
+        File[] files = recoveryDir.listFiles();
+        if (files == null || files.length == 0) {
+            AtmobLog.d(TAG, "No recovery files found.");
+            return Collections.emptyList();
+        }
+        List<File> recoveryFiles = new ArrayList<>();
+        for (File file : files) {
+            if (file.isFile() && file.length() > 0) {
+                recoveryFiles.add(file);
+            }
+        }
+        return recoveryFiles;
+    }
+
+    @Override
+    public void onProcessStarted() {
+        AtmobLog.d(TAG, "Process started, checking for gallery backup...");
+        startBackup();
+    }
+
+    @Override
+    public void onProcessResumed() {
+
+    }
+
+    @Override
+    public void onProcessPaused() {
+
+    }
+
+    @Override
+    public void onProcessStopped() {
+        AtmobLog.d(TAG, "Process stopped, resetting backup state...");
+        startBackup();
+    }
+
+    private void startBackup() {
+        if (PermissionUtil.hasFilePermission()) {
+            if (isBackupInProgress.getAndSet(true)) {
+                AtmobLog.w(TAG, "Backup already in progress, skipping this run.");
+                return; // backup already in progress
+            }
+            ThreadPool.getInstance().execute(() -> {
+                AtmobLog.d(TAG, "Starting gallery backup...");
+                long startTime = System.currentTimeMillis();
+                try {
+                    doBackup();
+                } finally {
+                    isBackupInProgress.set(false); // reset backup flag
+                    long duration = System.currentTimeMillis() - startTime;
+                    AtmobLog.d(TAG, "Gallery backup completed in %d ms", duration);
+                }
+            });
+        } else {
+            AtmobLog.w(TAG, "File permission not granted, cannot start gallery backup.");
+        }
+    }
+
+    private void doBackup() {
+        Uri collection;
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL);
+        } else {
+            collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+        }
+        String[] projection = new String[]{
+                MediaStore.Images.Media._ID,
+                MediaStore.Images.Media.DISPLAY_NAME,
+                MediaStore.Images.Media.MIME_TYPE,
+                MediaStore.Images.Media.DATE_ADDED
+        };
+        String sortOrder = MediaStore.Images.Media.DATE_ADDED + " DESC";
+        try (Cursor cursor = contentResolver.query(
+                collection,
+                projection,
+                null,
+                null,
+                sortOrder
+        )) {
+            if (cursor == null) {
+                return;
+            }
+            // backup top 100 images
+            int count = 0;
+            while (cursor.moveToNext() && count < 100) {
+                long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
+                Uri imageUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
+                long dateAdded = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED));
+                String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE));
+                String displayName = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME));
+                long bytesPerPixel = getBitmapSizePerPixel(displayName, mimeType);
+                backupImage(imageUri, bytesPerPixel);
+                recordDateAdded(dateAdded);
+                count++;
+            }
+        }
+
+        long oldestDate = KVUtils.getDefault().getLong(KEY_OLDEST_IMAGE_ADDED_DATE, Long.MAX_VALUE);
+        String selection = MediaStore.Images.Media.DATE_ADDED + " >= ?";
+        String[] selectionArgs = new String[]{String.valueOf(oldestDate)};
+        try (Cursor cursor = contentResolver.query(
+                collection,
+                projection,
+                selection,
+                selectionArgs,
+                sortOrder
+        )) {
+            if (cursor == null) {
+                return;
+            }
+            List<String> imagePaths = new ArrayList<>();
+            while (cursor.moveToNext()) {
+                long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
+                Uri imageUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
+                String path = imageUri.getPath();
+                if (path == null || path.isEmpty() || imagePaths.contains(path)) {
+                    continue; // skip if path is null or already processed
+                }
+                imagePaths.add(path);
+            }
+            checkRecoveredImages(imagePaths);
+        }
+        cleanupOldBackups();
+    }
+
+    private long getBitmapSizePerPixel(String displayName, String mimeType) {
+        if (displayName == null || displayName.isEmpty() || mimeType == null || mimeType.isEmpty()) {
+            return 3; // default to 3 bytes per pixel for unknown types
+        }
+        if (mimeType.equalsIgnoreCase("image/png")) {
+            return 4; // PNG typically uses 4 bytes per pixel
+        } else if (mimeType.equalsIgnoreCase("image/jpeg")) {
+            return 3; // JPEG typically uses 3 bytes per pixel
+        } else if (mimeType.equalsIgnoreCase("image/webp")) {
+            return 4; // WebP typically uses 4 bytes per pixel
+        }
+        return 3; // default to 4 bytes per pixel for unknown types
+    }
+
+    private void recordDateAdded(long dateAdded) {
+        long oldest = KVUtils.getDefault().getLong(KEY_OLDEST_IMAGE_ADDED_DATE, Long.MAX_VALUE);
+        if (dateAdded < oldest) {
+            KVUtils.getDefault().putLong(KEY_OLDEST_IMAGE_ADDED_DATE, dateAdded);
+        }
+    }
+
+    /**
+     * @noinspection ResultOfMethodCallIgnored
+     */
+    private void backupImage(Uri imageUri, long bytesPerPixel) {
+        String imagePath = imageUri.getPath();
+        if (imagePath == null || imagePath.isEmpty()) {
+            return; // invalid image URI
+        }
+        if (getExistsBackupFile(imagePath) != null || getExistsRecoveryFile(imagePath) != null) {
+            AtmobLog.d(TAG, "Image already backed up: %s", imagePath);
+            return; // already backed up
+        }
+        try (InputStream inputStream = contentResolver.openInputStream(imageUri)) {
+            BitmapFactory.Options options = new BitmapFactory.Options();
+            options.inJustDecodeBounds = true;
+            BitmapFactory.decodeStream(inputStream, null, options);
+            if (options.outWidth <= 0 || options.outHeight <= 0) {
+                AtmobLog.w(TAG, "Invalid image dimensions for URI: %s", imageUri);
+                return; // invalid image
+            }
+            // calculate image size, and check if it exceeds the limit, compress if necessary
+            long imageSize = (long) options.outWidth * options.outHeight * bytesPerPixel;
+            if (imageSize > MAX_BITMAP_SIZE) {
+                options.inSampleSize = (int) Math.floor(Math.sqrt((double) imageSize / MAX_BITMAP_SIZE));
+                options.inSampleSize = Math.max(options.inSampleSize, 1); // ensure at least 1
+            } else {
+                options.inSampleSize = 1; // no compression needed
+            }
+            AtmobLog.d(TAG, "Image size (%d bytes) exceeds limit (%d bytes), compressing with inSampleSize: %d",
+                    imageSize, MAX_BITMAP_SIZE, options.inSampleSize);
+            options.inJustDecodeBounds = false;
+            try (InputStream compressedInputStream = contentResolver.openInputStream(imageUri);
+                 ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+                Bitmap bitmap = BitmapFactory.decodeStream(compressedInputStream, null, options);
+                if (bitmap == null) {
+                    AtmobLog.w(TAG, "Failed to decode image from URI: %s", imageUri);
+                    return; // failed to decode image
+                }
+                compressBitmap(outputStream, bitmap);
+                File backupFile = new File(getBackupDir(), CryptoUtils.HASH.md5(imagePath));
+                if (backupFile.exists()) {
+                    backupFile.delete();
+                } else {
+                    backupFile.createNewFile();
+                }
+                try (FileOutputStream fileOutputStream = new FileOutputStream(backupFile)) {
+                    outputStream.writeTo(fileOutputStream);
+                    fileOutputStream.flush();
+                }
+                AtmobLog.d(TAG, "Image backed up successfully: %s, backup: %s",
+                        imagePath, backupFile.getPath());
+            }
+        } catch (Exception e) {
+            e.printStackTrace(); // handle exception
+        }
+    }
+
+    private static void compressBitmap(ByteArrayOutputStream outputStream, Bitmap bitmap) {
+        int low = 5; // Minimum bestQuality
+        int high = 100;
+        if (bitmap == null || outputStream == null) {
+            AtmobLog.w(TAG, "Bitmap or output stream is null, skipping compression.");
+            return; // invalid input
+        }
+        if (bitmap.getWidth() <= 0 || bitmap.getHeight() <= 0) {
+            AtmobLog.w(TAG, "Invalid bitmap dimensions: %dx%d, skipping compression.",
+                    bitmap.getWidth(), bitmap.getHeight());
+            return; // invalid bitmap
+        }
+        outputStream.reset();
+        bitmap.compress(Bitmap.CompressFormat.WEBP, high, outputStream);
+        int size = outputStream.size();
+        if (size <= MAX_SIZE_PER_IMAGE) {
+            AtmobLog.d(TAG, "Image already within size limit (%d bytes), no compression needed.", size);
+            return; // already within size limit
+        }
+        outputStream.reset();
+        bitmap.compress(Bitmap.CompressFormat.WEBP, low, outputStream);
+        size = outputStream.size();
+        if (size > MAX_SIZE_PER_IMAGE) {
+            AtmobLog.w(TAG, "Image size (%d bytes) exceeds limit (%d bytes), compressing further.", size, MAX_SIZE_PER_IMAGE);
+            return; // still exceeds size limit, need to compress further
+        }
+        while (low < high) {
+            int mid = (low + high) / 2;
+            outputStream.reset();
+            bitmap.compress(Bitmap.CompressFormat.WEBP, mid, outputStream);
+            size = outputStream.size();
+            if (size <= MAX_SIZE_PER_IMAGE) {
+                low = mid + 1; // Try for a better bestQuality
+            } else {
+                high = mid; // Reduce bestQuality
+            }
+        }
+    }
+
+    /**
+     * @noinspection ResultOfMethodCallIgnored
+     */
+    private void checkRecoveredImages(List<String> imagePaths) {
+        List<String> imagePathsMD5 = new ArrayList<>();
+        for (String path : imagePaths) {
+            String pathMd5 = CryptoUtils.HASH.md5(path);
+            imagePathsMD5.add(pathMd5);
+            AtmobLog.d(TAG, "Checking image: %s, MD5: %s", path, pathMd5);
+        }
+        File backupDir = getBackupDir();
+        File[] files = backupDir.listFiles((dir, name) -> {
+            if (name == null || name.isEmpty()) {
+                return false; // skip empty names
+            }
+            // Check if the file name (MD5 hash) is not in the list of image
+            if (imagePathsMD5.contains(name)) {
+                AtmobLog.d(TAG, "Image still exists, skipping recovery: %s", name);
+                return false; // skip if already backed up
+            } else {
+                AtmobLog.d(TAG, "Image not found in backup, recovering: %s", name);
+                return true; // recover this file
+            }
+        });
+        if (files == null) {
+            return; // no files to recover
+        }
+        for (File file : files) {
+            AtmobLog.d(TAG, "Recovering file: %s", file.getName());
+            // move file to recovery directory and delete from backup
+            if (file.isDirectory() || file.length() <= 0) {
+                continue;
+            }
+            File recoveryDir = getRecoveryDir();
+            File recoveryFile = new File(recoveryDir, file.getName());
+            if (recoveryFile.exists()) {
+                recoveryFile.delete(); // delete existing file in recovery dir
+            }
+            if (file.renameTo(recoveryFile)) {
+                AtmobLog.d(TAG, "Recovery successful: %s -> %s",
+                        file.getPath(), recoveryFile.getPath());
+                file.delete(); // delete from backup dir if moved successfully
+            }
+        }
+    }
+
+    private void cleanupOldBackups() {
+        File backupDir = getBackupDir();
+        File[] backupFiles = backupDir.listFiles();
+        if (backupFiles == null || backupFiles.length == 0) {
+            return; // No backups to clean up
+        }
+        long totalSize = 0;
+        for (File file : backupFiles) {
+            if (file.isFile()) {
+                totalSize += file.length();
+            }
+        }
+        if (totalSize <= MAX_BACKUP_SIZE) {
+            return; // No need to clean up
+        }
+        AtmobLog.d(TAG, "Total backup size (%d bytes) exceeds limit (%d bytes), cleaning up old backups...", totalSize, MAX_BACKUP_SIZE);
+        // Sort files by last modified time and delete oldest files until under limit
+        Arrays.sort(backupFiles, (f1, f2) -> Long.compare(f1.lastModified(), f2.lastModified()));
+        for (File file : backupFiles) {
+            if (totalSize <= MAX_BACKUP_SIZE) {
+                break; // Under limit, stop deleting
+            }
+            if (file.delete()) {
+                AtmobLog.d(TAG, "Deleted old backup file: %s", file.getName());
+                totalSize -= file.length();
+            }
+        }
+        AtmobLog.d(TAG, "Cleanup complete. Remaining backup size: %d bytes", totalSize);
+    }
+
+    /**
+     * @noinspection ResultOfMethodCallIgnored
+     */
+    private File getBackupDir() {
+        File backupDir = new File(rootDir, BACKUP_DIR);
+        if (backupDir.exists() && backupDir.isDirectory()) {
+            return backupDir;
+        }
+        backupDir.mkdir();
+        return backupDir;
+    }
+
+    /**
+     * @noinspection ResultOfMethodCallIgnored
+     */
+    private File getRecoveryDir() {
+        File recoveryDir = new File(rootDir, RECOVERY_DIR);
+        if (recoveryDir.exists() && recoveryDir.isDirectory()) {
+            return recoveryDir;
+        }
+        recoveryDir.mkdir();
+        return recoveryDir;
+    }
+
+    private File getExistsBackupFile(String imagePath) {
+        if (imagePath == null) {
+            return null;
+        }
+        String pathMd5 = CryptoUtils.HASH.md5(imagePath);
+        File backupFile = new File(getBackupDir(), pathMd5);
+        if (backupFile.exists() && backupFile.isFile() && backupFile.length() > 0) {
+            return backupFile;
+        }
+        return null;
+    }
+
+    private File getExistsRecoveryFile(String imagePath) {
+        if (imagePath == null) {
+            return null;
+        }
+        String pathMd5 = CryptoUtils.HASH.md5(imagePath);
+        File recoveryFile = new File(getRecoveryDir(), pathMd5);
+        if (recoveryFile.exists() && recoveryFile.isFile() && recoveryFile.length() > 0) {
+            return recoveryFile;
+        }
+        return null;
+    }
+}

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

@@ -11,7 +11,6 @@ import android.os.CancellationSignal;
 import android.os.Environment;
 import android.os.PowerManager;
 import android.text.TextUtils;
-import android.util.Log;
 
 import androidx.databinding.BaseObservable;
 import androidx.databinding.Bindable;
@@ -64,6 +63,7 @@ public class ImageDeepDetector {
     private static final int XIAOMI_GALLERY_CACHE = 7;
     private static final int MEIZU_GALLERY_CACHE = 8;
     private static final int HUAWEI_GALLERY_CACHE = 9;
+    private static final int GALLERY_RECOVERY = 10;
 
 
     public static Flowable<List<ImageFile>> detect(Context context, @MemberType String type) {
@@ -71,7 +71,11 @@ public class ImageDeepDetector {
         options.inJustDecodeBounds = true;
         return Flowable.create((FlowableOnSubscribe<XFile>) emitter -> {
                     try {
-
+                        for (File recoveryFile : GalleryBackupManager.getInstance().getRecoveryFiles()) {
+                            XFile xFile = new XPathFile(context, recoveryFile);
+                            xFile.setTag(GALLERY_RECOVERY);
+                            emitter.onNext(xFile);
+                        }
                         CancellationSignal cancellationSignal = XFileSearch.searchPreExternalStorageAsync(context, FileScanHelper.getPreScanImageRelatedDirectory(),
                                 new XFileSearch.FileFilter() {
 
@@ -118,6 +122,7 @@ public class ImageDeepDetector {
                 .flatMap((Function<XFile, Publisher<ImageFile>>) xFile -> {
                     int tag = (int) xFile.getTag();
                     switch (tag) {
+                        case GALLERY_RECOVERY:
                         case IMG_MAGIC:
                         case IMAGE_SUFFIX:
                             return Flowable.just(new ImageFile(xFile));
@@ -256,6 +261,9 @@ public class ImageDeepDetector {
             if (path.endsWith("files%2Fbddownload%2Fimg_download") || path.endsWith("files/bddownload/img_download")) {
                 return true;
             }
+            if (path.endsWith("Huawei/Themes") || path.endsWith("huawei%2fThemes")) {
+                return true;
+            }
             if (path.endsWith("__MACOSX")) {
                 return true;
             }
@@ -328,6 +336,14 @@ public class ImageDeepDetector {
 
         }
         try {
+            String path = file.getPath();
+            if (isGalleryBackup(path)) {
+                file.setTag(GALLERY_CACHE);
+                return true;
+            }
+        } catch (Exception ignore) {
+        }
+        try {
             String name = file.getName();
             if (isImageSuffix(name)) {
                 file.setTag(IMAGE_SUFFIX);
@@ -367,6 +383,10 @@ public class ImageDeepDetector {
         }
         try {
             String path = file.getPath();
+            if (isGalleryBackup(path)) {
+                file.setTag(GALLERY_CACHE);
+                return true;
+            }
             if (isWechatCacheFile(path)) {
                 file.setTag(WECHAT_CACHE);
                 return true;
@@ -438,6 +458,13 @@ public class ImageDeepDetector {
         return false;
     }
 
+    private static boolean isGalleryBackup(String path) {
+        if (TextUtils.isEmpty(path)) {
+            return false;
+        }
+        return path.contains(ContextUtil.getContext().getPackageName()) && path.contains("GalleryRecovery");
+    }
+
     private static boolean isGalleryCacheDirectory(String path) {
         if (TextUtils.isEmpty(path)) {
             return false;
@@ -791,7 +818,8 @@ public class ImageDeepDetector {
                             path.contains("OPPO") ||
                             path.contains("vivo") ||
                             path.contains("Samsung") ||
-                            path.contains("OnePlus")
+                            path.contains("OnePlus") ||
+                            path.contains("GalleryRecovery")
             );
         }
     }