diff --git a/app/build.gradle b/app/build.gradle
index 3d16872..8959b33 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -15,8 +15,8 @@ android {
applicationId "com.doubleangels.redact"
minSdk 31
targetSdk 37
- versionCode 20
- versionName "1.3.10"
+ versionCode 21
+ versionName "1.3.11"
def sentryRelease = "${applicationId}@${versionName}+${versionCode}"
buildConfigField "String", "SENTRY_RELEASE", "\"${sentryRelease}\""
@@ -45,6 +45,12 @@ android {
testOptions {
unitTests {
includeAndroidResources = true
+ all {
+ testLogging {
+ events "failed"
+ exceptionFormat "full"
+ }
+ }
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 915c8b4..ce7b989 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,6 +22,7 @@
android:name=".RedactApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
+ android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
diff --git a/app/src/main/java/com/doubleangels/redact/AppPreferences.java b/app/src/main/java/com/doubleangels/redact/AppPreferences.java
index dad000f..f6fb1bb 100644
--- a/app/src/main/java/com/doubleangels/redact/AppPreferences.java
+++ b/app/src/main/java/com/doubleangels/redact/AppPreferences.java
@@ -29,6 +29,7 @@ public final class AppPreferences {
private static final String KEY_STRICT_CLEAN = "strict_clean";
private static final String KEY_VIDEO_FALLBACK_COPY = "video_fallback_copy";
private static final String KEY_INITIAL_PERMISSIONS_PROMPTED = "initial_permissions_prompted";
+ private static final String KEY_MAPS_LOCATION_CONSENT_GIVEN = "maps_location_consent_given";
/** High quality — matches legacy defaults. */
public static final int QUALITY_PRESET_HIGH = 0;
@@ -250,4 +251,18 @@ public static boolean hasCompletedInitialPermissionsPrompt(@NonNull Context cont
public static void setInitialPermissionsPromptCompleted(@NonNull Context context) {
prefs(context).edit().putBoolean(KEY_INITIAL_PERMISSIONS_PROMPTED, true).apply();
}
+
+ /**
+ * Whether the user has already agreed, once, that opening a Scan coordinate in the device's
+ * maps app is okay. That action hands the GPS coordinates to a third-party app, which may
+ * in turn send them off-device (tile/geocoding requests), unlike every other Scan action,
+ * which stays entirely on-device. False until the user explicitly consents.
+ */
+ public static boolean hasConsentedToOpenLocationInMaps(@NonNull Context context) {
+ return prefs(context).getBoolean(KEY_MAPS_LOCATION_CONSENT_GIVEN, false);
+ }
+
+ public static void setConsentedToOpenLocationInMaps(@NonNull Context context, boolean consented) {
+ prefs(context).edit().putBoolean(KEY_MAPS_LOCATION_CONSENT_GIVEN, consented).apply();
+ }
}
diff --git a/app/src/main/java/com/doubleangels/redact/CacheCleanup.java b/app/src/main/java/com/doubleangels/redact/CacheCleanup.java
index 6decdf7..c2c0e60 100644
--- a/app/src/main/java/com/doubleangels/redact/CacheCleanup.java
+++ b/app/src/main/java/com/doubleangels/redact/CacheCleanup.java
@@ -3,11 +3,13 @@
import android.content.Context;
import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
import com.doubleangels.redact.media.SecureDelete;
import java.io.File;
import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
/**
* Utilities for measuring and clearing temporary processing files in app cache.
@@ -17,6 +19,14 @@ public final class CacheCleanup {
/** Default age for startup stale-temp cleanup (24 hours). */
public static final long DEFAULT_STALE_TEMP_MAX_AGE_MS = 24L * 60L * 60L * 1000L;
+ /**
+ * Ensures {@link #scheduleAutoCleanupIfEnabled} runs at most once per process, however many
+ * entry points call it (the Application on cold start, MainActivity on open).
+ */
+ private static final AtomicBoolean autoCleanupScheduledThisProcess = new AtomicBoolean(false);
+
+ private static volatile Thread autoCleanupThread;
+
private static final String PROCESSED_SUBDIR = "processed";
private static final String[] TEMP_PREFIXES = {
"temp_", "verify_", "vid_transform_", "vid_transmux_", "inbound_"
@@ -68,6 +78,55 @@ public static int clearAllTempFiles(@NonNull Context context) {
return clearStaleTempFiles(context, MIN_DELETE_AGE_MS);
}
+ /**
+ * Runs the stale-temp sweep in the background, at most once per process, as soon as any
+ * entry point reaches this call -- {@code RedactApplication.onCreate} on cold start, or
+ * {@code MainActivity.onCreate} on open, whichever comes first.
+ *
+ *
Previously this sweep only ran from {@code MainActivity}, so a user who only ever used
+ * the share-sheet entry point ({@code ShareHandlerActivity}) without opening the main app
+ * could leave unredacted source snapshots sitting in cache well past the 24-hour cutoff --
+ * bounded only by the next time they happened to launch the app, or uninstalled it.
+ */
+ public static void scheduleAutoCleanupIfEnabled(@NonNull Context context) {
+ if (!shouldRunAutoCleanup(context)) {
+ return;
+ }
+ Context appContext = context.getApplicationContext();
+ // Guarded to once per process, so a one-shot thread beats a never-released executor.
+ Thread thread = new Thread(() -> performAutoCleanup(appContext), "redact-auto-cleanup");
+ autoCleanupThread = thread;
+ thread.start();
+ }
+
+ @VisibleForTesting
+ static boolean shouldRunAutoCleanup(@NonNull Context context) {
+ if (!AppPreferences.isAutoClearTempFiles(context)
+ || ShareHandlerActivity.isShareProcessingActive()) {
+ return false;
+ }
+ return autoCleanupScheduledThisProcess.compareAndSet(false, true);
+ }
+
+ @VisibleForTesting
+ static void performAutoCleanup(@NonNull Context appContext) {
+ clearStaleTempFiles(appContext, DEFAULT_STALE_TEMP_MAX_AGE_MS);
+ }
+
+ @VisibleForTesting
+ public static void resetAutoCleanupStateForTests() {
+ // Let any sweep started by Application.onCreate finish so it can't race the test's files.
+ Thread thread = autoCleanupThread;
+ if (thread != null) {
+ try {
+ thread.join(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ autoCleanupScheduledThisProcess.set(false);
+ }
+
@NonNull
public static String formatSize(long bytes) {
if (bytes < 1024) {
diff --git a/app/src/main/java/com/doubleangels/redact/MainActivity.java b/app/src/main/java/com/doubleangels/redact/MainActivity.java
index 04a1c05..e374f86 100644
--- a/app/src/main/java/com/doubleangels/redact/MainActivity.java
+++ b/app/src/main/java/com/doubleangels/redact/MainActivity.java
@@ -33,17 +33,10 @@ public class MainActivity extends AppCompatActivity {
private static final String TAG_SETTINGS = "settings";
private static final String KEY_SELECTED_TAB = "selected_tab";
- @Nullable
- private java.util.concurrent.ExecutorService cacheClearExecutor;
-
@Override
protected void onDestroy() {
com.doubleangels.redact.permission.PermissionManager.setInitialFlowCompletedCallback(null);
com.doubleangels.redact.permission.PermissionManager.clearRuntimePermissionRequestOnDestroy();
- if (cacheClearExecutor != null) {
- cacheClearExecutor.shutdown();
- cacheClearExecutor = null;
- }
super.onDestroy();
}
@@ -67,14 +60,12 @@ protected void onCreate(Bundle savedInstanceState) {
SentryManager.logEvent("lifecycle", "MainActivity created");
- if (AppPreferences.isAutoClearTempFiles(this)
- && !ShareHandlerActivity.isShareProcessingActive()
- && !com.doubleangels.redact.ui.MainViewModel.isAnyProcessing(this)) {
- cacheClearExecutor = java.util.concurrent.Executors.newSingleThreadExecutor();
- cacheClearExecutor.execute(
- () -> com.doubleangels.redact.CacheCleanup.clearStaleTempFiles(
- getApplicationContext(),
- com.doubleangels.redact.CacheCleanup.DEFAULT_STALE_TEMP_MAX_AGE_MS));
+ // RedactApplication.onCreate already runs this sweep once per process; this call
+ // only matters when MainActivity is recreated without a fresh process (e.g. after a
+ // configuration change) and isAnyProcessing requires a live Activity to check, so it
+ // can't move into the Application-level call.
+ if (!com.doubleangels.redact.ui.MainViewModel.isAnyProcessing(this)) {
+ com.doubleangels.redact.CacheCleanup.scheduleAutoCleanupIfEnabled(this);
}
if (savedInstanceState == null) {
diff --git a/app/src/main/java/com/doubleangels/redact/RedactApplication.java b/app/src/main/java/com/doubleangels/redact/RedactApplication.java
index c66077a..ef6a5fe 100644
--- a/app/src/main/java/com/doubleangels/redact/RedactApplication.java
+++ b/app/src/main/java/com/doubleangels/redact/RedactApplication.java
@@ -26,6 +26,12 @@ public void onCreate() {
SentryManager.init(this);
SentryInitializer.initializeIfNeeded(this);
LocalNotifications.ensureChannels(this);
+
+ // Runs on every process start, including a cold start into ShareHandlerActivity via the
+ // share sheet -- not just when MainActivity happens to be opened -- so stale temp copies
+ // of source media (unredacted EXIF/GPS included) don't linger past the cutoff just
+ // because the user never opens the main app.
+ CacheCleanup.scheduleAutoCleanupIfEnabled(this);
}
@NonNull
diff --git a/app/src/main/java/com/doubleangels/redact/ScanFragment.java b/app/src/main/java/com/doubleangels/redact/ScanFragment.java
index a777633..286c588 100644
--- a/app/src/main/java/com/doubleangels/redact/ScanFragment.java
+++ b/app/src/main/java/com/doubleangels/redact/ScanFragment.java
@@ -1,8 +1,10 @@
package com.doubleangels.redact;
+import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
+import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Pair;
@@ -34,6 +36,8 @@
import com.doubleangels.redact.ui.ScanViewModel;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.divider.MaterialDividerItemDecoration;
import com.doubleangels.redact.sentry.SentryManager;
import java.util.ArrayList;
@@ -144,6 +148,13 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
scanMetadataAdapter = new ScanMetadataAdapter();
metadataItemsRecycler.setLayoutManager(new LinearLayoutManager(requireContext()));
metadataItemsRecycler.setAdapter(scanMetadataAdapter);
+ MaterialDividerItemDecoration metadataDivider = new MaterialDividerItemDecoration(
+ requireContext(), LinearLayoutManager.VERTICAL);
+ int dividerInset = getResources().getDimensionPixelSize(R.dimen.scan_metadata_row_horizontal_padding);
+ metadataDivider.setDividerInsetStart(dividerInset);
+ metadataDivider.setDividerInsetEnd(dividerInset);
+ metadataDivider.setLastItemDecorated(false);
+ metadataItemsRecycler.addItemDecoration(metadataDivider);
metadataItemsRecycler.setNestedScrollingEnabled(false);
metadataFooter = view.findViewById(R.id.metadataFooter);
metadataCard = view.findViewById(R.id.metadataCard);
@@ -498,9 +509,10 @@ private void updateScanActionCards(List> allRows) {
boolean hasCoords = !Double.isNaN(lastMapLatitude) && !Double.isNaN(lastMapLongitude)
&& MetadataDisplayer.isUsableMapCoordinate(lastMapLatitude, lastMapLongitude);
if (hasCoords) {
- String coordsLabel = String.format(Locale.US, "%.7f, %.7f", lastMapLatitude, lastMapLongitude);
- addScanActionCard(R.drawable.ic_map_24, getString(R.string.scan_copy_coordinates),
- v -> copyPlainTextToClipboard(coordsLabel));
+ double latForMaps = lastMapLatitude;
+ double lonForMaps = lastMapLongitude;
+ addScanActionCard(R.drawable.ic_map_24, getString(R.string.scan_open_coordinates_in_maps),
+ v -> onOpenCoordinatesInMaps(latForMaps, lonForMaps));
added = true;
}
@@ -586,6 +598,38 @@ private void copyPlainTextToClipboard(@NonNull String text) {
Toast.makeText(requireContext(), R.string.scan_copied_to_clipboard, Toast.LENGTH_SHORT).show();
}
+ /**
+ * Opening a coordinate in the maps app hands the GPS location to a separate, third-party
+ * app, unlike every other Scan action, which stays entirely on-device, so the first use
+ * requires explicit consent. Once given, it's remembered and this goes straight to Maps.
+ */
+ private void onOpenCoordinatesInMaps(double latitude, double longitude) {
+ if (AppPreferences.hasConsentedToOpenLocationInMaps(requireContext())) {
+ openCoordinatesInMaps(latitude, longitude);
+ return;
+ }
+ new MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.scan_maps_consent_title)
+ .setMessage(R.string.scan_maps_consent_message)
+ .setPositiveButton(R.string.scan_maps_consent_continue, (dialog, which) -> {
+ AppPreferences.setConsentedToOpenLocationInMaps(requireContext(), true);
+ openCoordinatesInMaps(latitude, longitude);
+ })
+ .setNegativeButton(R.string.button_cancel, null)
+ .show();
+ }
+
+ private void openCoordinatesInMaps(double latitude, double longitude) {
+ String coords = String.format(Locale.US, "%.7f,%.7f", latitude, longitude);
+ Uri geoUri = Uri.parse("geo:" + coords + "?q=" + coords);
+ Intent intent = new Intent(Intent.ACTION_VIEW, geoUri);
+ try {
+ startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(requireContext(), R.string.scan_maps_not_available, Toast.LENGTH_SHORT).show();
+ }
+ }
+
private void openInCleanTab(@NonNull MediaItem item) {
MainViewModel viewModel = new ViewModelProvider(requireActivity()).get(MainViewModel.class);
viewModel.setSelectedItems(List.of(item));
diff --git a/app/src/main/java/com/doubleangels/redact/ShareHandlerActivity.java b/app/src/main/java/com/doubleangels/redact/ShareHandlerActivity.java
index 41fddfb..76f7887 100644
--- a/app/src/main/java/com/doubleangels/redact/ShareHandlerActivity.java
+++ b/app/src/main/java/com/doubleangels/redact/ShareHandlerActivity.java
@@ -58,6 +58,15 @@ public class ShareHandlerActivity extends AppCompatActivity {
private static final java.util.concurrent.atomic.AtomicInteger activeShareSessions =
new java.util.concurrent.atomic.AtomicInteger(0);
+ /**
+ * Runs secure-delete cleanup off the calling thread. Overwriting a large video file with
+ * random data multiple times plus fsync (see {@link SecureDelete}) can take long enough to
+ * trip Android's ANR watchdog when it runs synchronously on the main thread, which is where
+ * cancel/finish/onDestroy call into cleanup.
+ */
+ private static final java.util.concurrent.ExecutorService cleanupExecutor =
+ java.util.concurrent.Executors.newSingleThreadExecutor();
+
public static boolean isShareProcessingActive() {
return activeShareSessions.get() > 0;
}
@@ -259,7 +268,12 @@ private void handleSentMedia(Intent intent) {
finishWithError(getString(R.string.share_error_failed_receive_media));
return;
}
- maybeConfirmAndProcess(snapshotInboundUris(accepted));
+ List snapshotted = snapshotInboundUris(accepted);
+ if (snapshotted.isEmpty()) {
+ finishWithError(getString(R.string.share_error_failed_receive_media));
+ return;
+ }
+ maybeConfirmAndProcess(snapshotted);
} catch (Exception e) {
SentryManager.recordException(e);
finishWithError(getString(R.string.share_error_generic));
@@ -279,8 +293,13 @@ private void handleMultipleMedia(Intent intent) {
finishWithError(getString(R.string.share_error_failed_receive_media));
return;
}
- SentryManager.setCustomKey("media_count", accepted.size());
- maybeConfirmAndProcess(snapshotInboundUris(accepted));
+ List snapshotted = snapshotInboundUris(accepted);
+ if (snapshotted.isEmpty()) {
+ finishWithError(getString(R.string.share_error_failed_receive_media));
+ return;
+ }
+ SentryManager.setCustomKey("media_count", snapshotted.size());
+ maybeConfirmAndProcess(snapshotted);
} else {
SentryManager.logEvent("share", "Received empty media list");
finishWithError(getString(R.string.share_error_failed_receive_media));
@@ -291,9 +310,13 @@ private void handleMultipleMedia(Intent intent) {
}
}
+ /** Items dropped by {@link #snapshotInboundUris}; counted as failures in the final toast. */
+ private volatile int snapshotFailCount;
+
@NonNull
private List snapshotInboundUris(@NonNull List uris) {
List stable = new ArrayList<>();
+ snapshotFailCount = 0;
for (Uri uri : uris) {
String scheme = uri.getScheme();
if ("content".equalsIgnoreCase(scheme) || "file".equalsIgnoreCase(scheme)) {
@@ -304,8 +327,13 @@ private List snapshotInboundUris(@NonNull List uris) {
}
stable.add(Uri.fromFile(snapshot));
} catch (IOException e) {
+ // The source is already unreadable here, so forwarding the original URI
+ // would only fail identically at every later processing stage (extractor,
+ // transcoder, verifier), each logging its own duplicate exception for what
+ // is really a single failure. Drop this item instead, but count it so the
+ // user still sees the partial-success message.
+ snapshotFailCount++;
SentryManager.recordException(e);
- stable.add(uri);
}
} else {
stable.add(uri);
@@ -403,7 +431,7 @@ private void processMediaItems(List uris) {
Thread worker = new Thread(() -> {
ITransaction transaction = SentryManager.startTransaction("share_cleanup", "task");
ArrayList processedUris = new ArrayList<>();
- int failCount = 0;
+ int failCount = snapshotFailCount;
boolean hasVideo = false;
boolean hasImage = false;
@@ -639,12 +667,17 @@ private void cleanupInboundSnapshots() {
filesToDelete = new ArrayList<>(inboundSnapshotFiles);
inboundSnapshotFiles.clear();
}
+ if (filesToDelete.isEmpty()) {
+ return;
+ }
Context appContext = getApplicationContext();
- for (File file : filesToDelete) {
- if (file != null && file.exists() && !SecureDelete.secureDelete(appContext, file)) {
- file.deleteOnExit();
+ cleanupExecutor.execute(() -> {
+ for (File file : filesToDelete) {
+ if (file != null && file.exists() && !SecureDelete.secureDelete(appContext, file)) {
+ file.deleteOnExit();
+ }
}
- }
+ });
}
private void cleanupProcessedFiles() {
@@ -655,7 +688,11 @@ private void cleanupProcessedFiles() {
processedFiles.clear();
processedDisplayNames.clear();
}
- deleteProcessedFileList(getApplicationContext(), filesToDelete);
+ if (filesToDelete.isEmpty()) {
+ return;
+ }
+ Context appContext = getApplicationContext();
+ cleanupExecutor.execute(() -> deleteProcessedFileList(appContext, filesToDelete));
}
private void scheduleDelayedShareCleanup() {
@@ -671,7 +708,8 @@ private void scheduleDelayedShareCleanup() {
Context appContext = getApplicationContext();
Handler handler = new Handler(appContext.getMainLooper());
handler.postDelayed(
- () -> deleteProcessedFileList(appContext, filesToDelete), SHARE_CLEANUP_DELAY_MS);
+ () -> cleanupExecutor.execute(() -> deleteProcessedFileList(appContext, filesToDelete)),
+ SHARE_CLEANUP_DELAY_MS);
}
private static void deleteProcessedFileList(@NonNull Context context, List files) {
diff --git a/app/src/main/java/com/doubleangels/redact/media/ConvertFileAdapter.java b/app/src/main/java/com/doubleangels/redact/media/ConvertFileAdapter.java
index d825169..dfba8c2 100644
--- a/app/src/main/java/com/doubleangels/redact/media/ConvertFileAdapter.java
+++ b/app/src/main/java/com/doubleangels/redact/media/ConvertFileAdapter.java
@@ -7,6 +7,7 @@
import android.widget.TextView;
import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
@@ -26,11 +27,42 @@ public final class ConvertFileAdapter extends RecyclerView.AdapterUses DiffUtil (matching {@link MediaAdapter#updateItems}) instead of
+ * {@code notifyDataSetChanged()} so unaffected rows keep their already-loaded Glide
+ * thumbnail instead of reloading/flickering on every edit to the selection.
+ */
public void setItems(@NonNull List newItems) {
+ final List oldItems = new ArrayList<>(items);
+
+ DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffUtil.Callback() {
+ @Override
+ public int getOldListSize() {
+ return oldItems.size();
+ }
+
+ @Override
+ public int getNewListSize() {
+ return newItems.size();
+ }
+
+ @Override
+ public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
+ return oldItems.get(oldItemPosition).uri().equals(
+ newItems.get(newItemPosition).uri());
+ }
+
+ @Override
+ public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
+ return oldItems.get(oldItemPosition).equals(newItems.get(newItemPosition));
+ }
+ });
+
items.clear();
items.addAll(newItems);
- notifyDataSetChanged();
+ diffResult.dispatchUpdatesTo(this);
}
@NonNull
diff --git a/app/src/main/java/com/doubleangels/redact/media/FormatConverter.java b/app/src/main/java/com/doubleangels/redact/media/FormatConverter.java
index b6781ec..fab6f8d 100644
--- a/app/src/main/java/com/doubleangels/redact/media/FormatConverter.java
+++ b/app/src/main/java/com/doubleangels/redact/media/FormatConverter.java
@@ -537,28 +537,6 @@ private static Bitmap decodeWithImageDecoder(Context context, Uri sourceUri)
});
}
- private static String stripExtension(String name) {
- if (name == null || name.isEmpty()) {
- return "converted";
- }
- int dot = name.lastIndexOf('.');
- if (dot > 0) {
- return name.substring(0, dot);
- }
- return name;
- }
-
- private static String sanitizeFileName(String name) {
- String n = name.replaceAll("[^a-zA-Z0-9._-]", "_");
- if (n.isEmpty()) {
- return "converted";
- }
- if (n.length() > 80) {
- return n.substring(0, 80);
- }
- return n;
- }
-
private static String extensionForFormat(Bitmap.CompressFormat format) {
if (format == Bitmap.CompressFormat.PNG) {
return ".png";
diff --git a/app/src/main/java/com/doubleangels/redact/media/VideoMedia3Converter.java b/app/src/main/java/com/doubleangels/redact/media/VideoMedia3Converter.java
index d568714..7116467 100644
--- a/app/src/main/java/com/doubleangels/redact/media/VideoMedia3Converter.java
+++ b/app/src/main/java/com/doubleangels/redact/media/VideoMedia3Converter.java
@@ -551,26 +551,4 @@ public static Uri copyToMoviesRedact(Context context, File file, String baseDisp
MediaStoreWrites.markPublished(resolver, outUri);
return outUri;
}
-
- private static String stripExtension(String name) {
- if (name == null || name.isEmpty()) {
- return "converted";
- }
- int dot = name.lastIndexOf('.');
- if (dot > 0) {
- return name.substring(0, dot);
- }
- return name;
- }
-
- private static String sanitizeFileName(String name) {
- String n = name.replaceAll("[^a-zA-Z0-9._-]", "_");
- if (n.isEmpty()) {
- return "converted";
- }
- if (n.length() > 80) {
- return n.substring(0, 80);
- }
- return n;
- }
}
diff --git a/app/src/main/java/com/doubleangels/redact/metadata/MetadataStripper.java b/app/src/main/java/com/doubleangels/redact/metadata/MetadataStripper.java
index 5730faa..5c29dae 100644
--- a/app/src/main/java/com/doubleangels/redact/metadata/MetadataStripper.java
+++ b/app/src/main/java/com/doubleangels/redact/metadata/MetadataStripper.java
@@ -9,8 +9,6 @@
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
-import android.provider.OpenableColumns;
-import android.database.Cursor;
import android.util.Log;
import java.util.Locale;
@@ -43,7 +41,6 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.imaging.bytesource.ByteSource;
import org.apache.commons.imaging.formats.jpeg.xmp.JpegXmpRewriter;
@@ -166,12 +163,6 @@ void setTestForceXmpReadFailure(boolean force) {
*/
private final Map preservedExifValues = new HashMap<>();
- /**
- * Cache for file sizes to avoid redundant I/O operations.
- * Key: URI string, Value: File size in bytes
- */
- private final Map fileSizeCache = new ConcurrentHashMap<>();
-
private final java.util.concurrent.atomic.AtomicBoolean operationCancelled =
new java.util.concurrent.atomic.AtomicBoolean(false);
@@ -1139,72 +1130,6 @@ FormatConverter.ImageFormatSpec resolveImageOutputFormat(
return FormatConverter.resolveImageFormat(extension, mimeType);
}
- /**
- * Determines the size of a file from its URI.
- *
- * This method tries multiple approaches to get the file size:
- * 1. OpenableColumns.SIZE (content URIs)
- * 2. File length for {@code file://} URIs
- * 3. AssetFileDescriptor length
- * 4. Reading the stream (last resort)
- *
- * @param uri URI of the file to check, must not be null
- * @return Size of the file in bytes, or 0 if size couldn't be determined
- */
- private long getFileSizeFromUri(@NonNull Uri uri) {
- try {
- if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) {
- try (Cursor cursor = contentResolver.query(uri,
- new String[]{OpenableColumns.SIZE}, null, null, null)) {
- if (cursor != null && cursor.moveToFirst()) {
- int idx = cursor.getColumnIndex(OpenableColumns.SIZE);
- if (idx >= 0 && !cursor.isNull(idx)) {
- long sz = cursor.getLong(idx);
- if (sz > 0) {
- return sz;
- }
- }
- }
- }
- } else if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
- String path = uri.getPath();
- if (path != null) {
- File f = new File(path);
- if (f.isFile()) {
- return f.length();
- }
- }
- }
-
- try (android.content.res.AssetFileDescriptor afd = contentResolver.openAssetFileDescriptor(uri, "r")) {
- if (afd != null) {
- long size = afd.getLength();
- if (size > 0) {
- return size;
- }
- }
- } catch (Exception ignored) {
- // Fall through to stream-based approach
- }
-
- try (InputStream stream = contentResolver.openInputStream(uri)) {
- if (stream == null) {
- return 0;
- }
- long size = 0;
- byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
- int bytesRead;
- while ((bytesRead = stream.read(buffer)) != -1) {
- size += bytesRead;
- }
- return size;
- }
- } catch (Exception e) {
- SentryManager.log("Error determining file size: " + e.getMessage() + ".");
- return 0;
- }
- }
-
/**
* Reads and stores essential EXIF data from an image file that should be
* preserved.
@@ -1479,17 +1404,31 @@ static VideoPrivacySnapshot empty() {
}
}
+ /**
+ * MediaMetadataRetriever reports this exact string for METADATA_KEY_DATE when the source
+ * container's creation_time atom is zero (the QuickTime/MP4 epoch, 1904-01-01) -- i.e. no
+ * real recording date was ever set. Our own muxer/encoder output surfaces this identical
+ * placeholder whenever it writes no explicit date, so comparing it as if it were a real date
+ * makes every date-less source file fail verification against its own, equally date-less,
+ * output.
+ */
+ private static final String VIDEO_DATE_UNSET_PLACEHOLDER = "19040101T000000.000Z";
+
@NonNull
private VideoPrivacySnapshot extractVideoPrivacyMetadata(@NonNull Uri sourceUri) {
try (android.media.MediaMetadataRetriever retriever = new android.media.MediaMetadataRetriever()) {
retriever.setDataSource(context, sourceUri);
+ String date = normalizeMetadataValue(
+ retriever.extractMetadata(
+ android.media.MediaMetadataRetriever.METADATA_KEY_DATE));
+ if (VIDEO_DATE_UNSET_PLACEHOLDER.equals(date)) {
+ date = null;
+ }
return new VideoPrivacySnapshot(
normalizeMetadataValue(
retriever.extractMetadata(
android.media.MediaMetadataRetriever.METADATA_KEY_LOCATION)),
- normalizeMetadataValue(
- retriever.extractMetadata(
- android.media.MediaMetadataRetriever.METADATA_KEY_DATE)));
+ date);
} catch (Exception e) {
SentryManager.log("Could not read source video privacy metadata: " + e.getMessage());
return VideoPrivacySnapshot.empty();
@@ -1848,34 +1787,6 @@ private void copySourceToTempFile(@NonNull Uri sourceUri, @NonNull File tempFile
}
}
- /**
- * Gets file size from URI with caching to avoid redundant I/O operations.
- *
- * @param uri URI of the file
- * @return File size in bytes, or 0 if size couldn't be determined
- */
- private long getFileSizeFromUriCached(@NonNull Uri uri) {
- String uriString = uri.toString();
-
- // Check cache first
- Long cachedSize = fileSizeCache.get(uriString);
- if (cachedSize != null) {
- return cachedSize;
- }
-
- // Get size and cache it
- long size = getFileSizeFromUri(uri);
- if (size > 0) {
- fileSizeCache.put(uriString, size);
- }
-
- return size;
- }
-
- /**
- * Clears the file size cache.
- */
-
/**
* Attempts to strip EXIF, XMP, and IPTC losslessly using apache commons-imaging.
* @return true if successful, false if it failed or was not a JPEG.
@@ -1965,7 +1876,4 @@ private boolean stripMetadataNativeExif(
}
}
- public void clearFileSizeCache() {
- fileSizeCache.clear();
- }
}
diff --git a/app/src/main/java/com/doubleangels/redact/notifications/ProcessingForegroundService.java b/app/src/main/java/com/doubleangels/redact/notifications/ProcessingForegroundService.java
index 139dd90..26272d7 100644
--- a/app/src/main/java/com/doubleangels/redact/notifications/ProcessingForegroundService.java
+++ b/app/src/main/java/com/doubleangels/redact/notifications/ProcessingForegroundService.java
@@ -157,6 +157,28 @@ public void onTaskRemoved(Intent rootIntent) {
super.onTaskRemoved(rootIntent);
}
+ /**
+ * Android 15+ (API 35): {@code dataSync}/{@code mediaProcessing} foreground services get a
+ * background execution time limit; the OS calls this shortly before stopping the service so
+ * it can wind down instead of being killed outright. Declared unconditionally -- overriding
+ * a callback newer than minSdk is safe (older platforms simply never invoke it) -- so there
+ * is no {@code @RequiresApi} guard to add here.
+ */
+ @Override
+ public void onTimeout(int startId, int fgsType) {
+ com.doubleangels.redact.sentry.SentryManager.log(
+ "ProcessingForegroundService hit the Android background execution time limit");
+ com.doubleangels.redact.media.AppProcessingScope scope =
+ com.doubleangels.redact.media.AppProcessingScope.get(getApplicationContext());
+ scope.mediaProcessor().cancel();
+ // Same signal MainViewModel's cancel uses: the Convert loop bails when its generation moves.
+ scope.convertGeneration().incrementAndGet();
+ scope.convertInProgress().set(false);
+ com.doubleangels.redact.media.VideoMedia3Converter.cancelActiveTranscode();
+ stopForeground(STOP_FOREGROUND_REMOVE);
+ stopSelf();
+ }
+
@Nullable
@Override
public IBinder onBind(Intent intent) {
diff --git a/app/src/main/java/com/doubleangels/redact/permission/PermissionManager.java b/app/src/main/java/com/doubleangels/redact/permission/PermissionManager.java
index 8ed2260..441439e 100644
--- a/app/src/main/java/com/doubleangels/redact/permission/PermissionManager.java
+++ b/app/src/main/java/com/doubleangels/redact/permission/PermissionManager.java
@@ -7,7 +7,6 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
@@ -34,7 +33,6 @@ public class PermissionManager {
public static final int STORAGE_PERMISSION_REQUEST_CODE = 123;
public static final int LOCATION_PERMISSION_REQUEST_CODE = 124;
public static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 125;
- private static final String TAG = "PermissionManager";
private enum InitialFlowStep {
MEDIA,
@@ -296,15 +294,6 @@ public void requestLocationPermission() {
}
}
- @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
- private void requestMediaPermissions() {
- requestMissingRuntimePermissions(activity);
- }
-
- private void requestStoragePermissions() {
- requestMissingRuntimePermissions(activity);
- }
-
private void requestMediaLocationPermission() {
if (!needsLocationPermission()) {
return;
diff --git a/app/src/main/res/layout/fragment_scan.xml b/app/src/main/res/layout/fragment_scan.xml
index b4817fa..f83af37 100644
--- a/app/src/main/res/layout/fragment_scan.xml
+++ b/app/src/main/res/layout/fragment_scan.xml
@@ -56,13 +56,24 @@
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
-
+ android:layout_marginTop="16dp"
+ app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
+ app:cardCornerRadius="@dimen/component_corner_medium"
+ app:cardElevation="0dp"
+ app:strokeWidth="0dp">
+
+
+
+ android:paddingHorizontal="@dimen/scan_metadata_row_horizontal_padding"
+ android:paddingVertical="12dp">
+ android:textColor="?attr/colorPrimary"
+ tools:text="Display name" />
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 949afc0..654e0a4 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -269,7 +269,6 @@
تم النسخ إلى الحافظة
نسخ كل البيانات الوصفية
نسخ معلومات الكاميرا
- نسخ الإحداثيات
موقع الصورة
لا يمكن المسح أثناء المعالجة.
تعذر تجهيز الملف المنظف للمشاركة.
@@ -323,4 +322,9 @@
جارٍ إعادة الترميز إلى الصيغة المطلوبة…
جارٍ إعادة تعبئة الفيديو…
جارٍ التحقق من إزالة البيانات الوصفية…
+ فتح في الخرائط
+ فتح الموقع في الخرائط؟
+ سيؤدي هذا إلى فتح الإحداثيات في تطبيق الخرائط الافتراضي على جهازك. على عكس كل إجراء آخر في هذه الشاشة، فإنه يسلّم موقع GPS الدقيق إلى تطبيق منفصل قد يرسله خارج هذا الجهاز، مثلًا لتحميل مربعات الخريطة أو البحث عن العنوان. أما Redact نفسه فلا يرسل هذه البيانات إلى أي مكان.\n\nسترى هذه الرسالة مرة واحدة فقط. سيتم تذكّر اختيارك المتابعة، لذا لن يُطلب منك ذلك مجددًا.
+ فتح الخرائط
+ لا يتوفر تطبيق خرائط لفتح هذا الموقع.
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 16134b3..142fe91 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -268,7 +268,6 @@ Ihre Fotos und Videos werden nie hochgeladen. URIs, Dateinamen und GPS-Koordinat
In Zwischenablage kopiert
Alle Metadaten Kopieren
Kamerainfo Kopieren
- Koordinaten Kopieren
Foto-Standort
Löschen während der Verarbeitung nicht möglich.
Bereinigte Datei konnte nicht für die Freigabe vorbereitet werden.
@@ -322,4 +321,9 @@ Nur aktivieren, wenn Sie den Standort absichtlich behalten möchten.
Wird in Zielformat transkodiert…
Container wird neu gemuxt…
Überprüfung der Metadatenentfernung…
+ In Karten öffnen
+ Standort in Karten öffnen?
+ Dadurch werden die Koordinaten in der Standard-Karten-App Ihres Geräts geöffnet. Anders als jede andere Aktion auf diesem Bildschirm übergibt dies Ihren genauen GPS-Standort an eine separate App, die ihn von diesem Gerät senden kann, zum Beispiel um Kartenkacheln zu laden oder die Adresse nachzuschlagen. Redact selbst sendet diese Daten nirgendwohin.\n\nSie sehen diese Meldung nur einmal. Ihre Entscheidung, fortzufahren, wird gespeichert, daher wird nicht erneut gefragt.
+ Karten öffnen
+ Es ist keine Karten-App zum Öffnen dieses Standorts verfügbar.
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 28245d2..b470bef 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -269,7 +269,6 @@ Tus fotos y videos nunca se suben. Se eliminan URIs, nombres de archivo y coorde
Copiado al portapapeles
Copiar Todos los Metadatos
Copiar Info de Cámara
- Copiar Coordenadas
Ubicación de la Foto
No se puede borrar mientras hay un proceso en curso.
No se pudo preparar el archivo limpio para compartir.
@@ -323,4 +322,9 @@ Activa esto solo si quieres conservar la ubicación a propósito.
Transcodificando al formato de destino…
Remultiplexando video…
Verificando la eliminación de metadatos…
+ Abrir en Mapas
+ ¿Abrir la ubicación en Mapas?
+ Esto abre las coordenadas en la aplicación de mapas predeterminada de tu dispositivo. A diferencia de cualquier otra acción de esta pantalla, entrega tu ubicación GPS exacta a una aplicación independiente, que podría enviarla fuera de este dispositivo, por ejemplo para cargar teselas del mapa o buscar la dirección. Redact nunca envía estos datos a ningún lugar.\n\nSolo verás este mensaje una vez. Tu decisión de continuar se recuerda, así que no volverá a preguntar.
+ Abrir Mapas
+ No hay ninguna aplicación de mapas disponible para abrir esta ubicación.
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 84c55c6..d9dd5fe 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -269,7 +269,6 @@ Vos photos et vidéos ne sont jamais téléversées. Les URI, noms de fichier et
Copié dans le presse-papiers
Copier Toutes les Métadonnées
Copier les Infos Appareil Photo
- Copier les Coordonnées
Emplacement de la Photo
Impossible de vider pendant un traitement en cours.
Impossible de préparer le fichier nettoyé pour le partage.
@@ -323,4 +322,9 @@ Activez ceci uniquement si vous souhaitez conserver la position volontairement.<
Transcodage vers le format cible en cours…
Remultiplexage en cours…
Vérification de la suppression des métadonnées en cours…
+ Ouvrir dans Plans
+ Ouvrir la position dans Plans ?
+ Cela ouvre les coordonnées dans l\'application de cartes par défaut de votre appareil. Contrairement à toutes les autres actions de cet écran, cela transmet votre position GPS exacte à une application distincte, qui peut l\'envoyer hors de cet appareil, par exemple pour charger les tuiles de la carte ou rechercher l\'adresse. Redact n\'envoie jamais ces données nulle part.\n\nVous ne verrez ce message qu\'une seule fois. Votre choix de continuer est mémorisé, il ne sera donc plus demandé.
+ Ouvrir Plans
+ Aucune application de cartes n\'est disponible pour ouvrir cette position.
\ No newline at end of file
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 0de1e25..654a74c 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -269,7 +269,6 @@
क्लिपबोर्ड पर कॉपी किया गया
सभी मेटाडेटा कॉपी करें
कैमरा जानकारी कॉपी करें
- निर्देशांक कॉपी करें
फ़ोटो स्थान
प्रसंस्करण चल रहा होने पर साफ़ नहीं किया जा सकता।
साफ़ की गई फ़ाइल साझा करने के लिए तैयार नहीं की जा सकी।
@@ -323,4 +322,9 @@
लक्ष्य प्रारूप में ट्रांसकोड किया जा रहा है…
ट्रांसमक्स किया जा रहा है…
मेटाडेटा निष्कासन सत्यापित किया जा रहा है…
+ मैप्स में खोलें
+ स्थान को मैप्स में खोलें?
+ यह निर्देशांक को आपके डिवाइस के डिफ़ॉल्ट मैप ऐप में खोलता है। इस स्क्रीन की हर दूसरी कार्रवाई के विपरीत, यह आपका सटीक GPS स्थान एक अलग ऐप को सौंप देता है, जो इसे इस डिवाइस से बाहर भेज सकता है, जैसे मैप टाइल लोड करने या पता खोजने के लिए। Redact स्वयं यह डेटा कहीं नहीं भेजता।\n\nआपको यह संदेश केवल एक बार दिखेगा। जारी रखने का आपका चुनाव याद रखा जाता है, इसलिए यह दोबारा नहीं पूछेगा।
+ मैप्स खोलें
+ इस स्थान को खोलने के लिए कोई मैप ऐप उपलब्ध नहीं है।
\ No newline at end of file
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 66a9ea6..b3861dd 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -269,7 +269,6 @@ Le tue foto e video non vengono mai caricati. URI, nomi file e coordinate GPS ve
Copiato negli appunti
Copia Tutti I Metadati
Copia Info Fotocamera
- Copia Coordinate
Posizione Foto
Impossibile svuotare durante l\'elaborazione.
Impossibile preparare il file pulito per la condivisione.
@@ -323,4 +322,9 @@ Attiva solo se vuoi mantenere la posizione intenzionalmente.
Transcodifica nel formato di destinazione in corso…
Remux del contenitore in corso…
Verifica della rimozione dei metadati in corso…
+ Apri in Mappe
+ Aprire la posizione in Mappe?
+ Questo apre le coordinate nell\'app di mappe predefinita del dispositivo. A differenza di ogni altra azione di questa schermata, consegna la tua posizione GPS esatta a un\'app separata, che potrebbe inviarla fuori da questo dispositivo, ad esempio per caricare i riquadri della mappa o cercare l\'indirizzo. Redact non invia mai questi dati da nessuna parte.\n\nVedrai questo messaggio solo una volta. La scelta di continuare viene ricordata, quindi non verrà chiesto di nuovo.
+ Apri Mappe
+ Nessuna app di mappe disponibile per aprire questa posizione.
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 05571e0..86c29b3 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -270,7 +270,6 @@
クリップボードにコピーしました
すべてのメタデータをコピー
カメラ情報をコピー
- 座標をコピー
写真の位置情報
処理中はクリアできません。
クリーンしたファイルを共有用に準備できませんでした。
@@ -324,4 +323,9 @@
目的の形式にトランスコード中…
コンテナを再構成中…
メタデータの削除を確認中…
+ マップで開く
+ 位置情報をマップで開きますか?
+ 座標をデバイスの既定のマップアプリで開きます。この画面の他の操作とは異なり、正確なGPS位置情報が別のアプリに渡され、そのアプリが地図タイルの読み込みや住所の検索などのためにこのデバイス外へ送信する可能性があります。Redact自体がこのデータをどこにも送信することはありません。\n\nこのメッセージは一度だけ表示されます。続行を選択すると記憶され、再度確認されません。
+ マップを開く
+ この位置情報を開けるマップアプリがありません。
\ No newline at end of file
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 7867edb..8a0a1b3 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -269,7 +269,6 @@
클립보드에 복사됨
모든 메타데이터 복사
카메라 정보 복사
- 좌표 복사
사진 위치
처리 중에는 지울 수 없습니다.
정리된 파일을 공유할 준비하지 못했습니다.
@@ -323,4 +322,9 @@
대상 형식으로 트랜스코딩 중…
컨테이너 재구성 중…
메타데이터 삭제 확인 중…
+ 지도에서 열기
+ 지도에서 위치를 열까요?
+ 좌표를 기기의 기본 지도 앱에서 엽니다. 이 화면의 다른 모든 작업과 달리, 정확한 GPS 위치를 별도의 앱에 전달하며 해당 앱은 지도 타일을 불러오거나 주소를 조회하기 위해 이 기기 밖으로 전송할 수 있습니다. Redact 자체는 이 데이터를 어디에도 전송하지 않습니다.\n\n이 메시지는 한 번만 표시됩니다. 계속하기를 선택하면 기억되므로 다시 묻지 않습니다.
+ 지도 열기
+ 이 위치를 열 수 있는 지도 앱이 없습니다.
\ No newline at end of file
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 2c73074..389b909 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -269,7 +269,6 @@ Suas fotos e vídeos nunca são enviados. URIs, nomes de arquivo e coordenadas G
Copiado para a área de transferência
Copiar Todos Os Metadados
Copiar Info da Câmera
- Copiar Coordenadas
Localização da Foto
Não é possível limpar durante o processamento.
Não foi possível preparar o arquivo limpo para compartilhar.
@@ -323,4 +322,9 @@ Ative apenas se quiser manter a localização de propósito.
Transcodificando para o formato de destino…
Remultiplexando o vídeo…
Verificando remoção de metadados…
+ Abrir no Mapas
+ Abrir a localização no Mapas?
+ Isso abre as coordenadas no app de mapas padrão do seu dispositivo. Ao contrário de todas as outras ações desta tela, isso entrega sua localização GPS exata a um app separado, que pode enviá-la para fora deste dispositivo, por exemplo para carregar blocos do mapa ou buscar o endereço. O próprio Redact nunca envia esses dados a lugar nenhum.\n\nVocê verá esta mensagem apenas uma vez. Sua escolha de continuar é lembrada, então não será perguntado novamente.
+ Abrir Mapas
+ Nenhum app de mapas disponível para abrir esta localização.
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 4f2c190..8778b56 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -269,7 +269,6 @@
Скопировано в буфер обмена
Копировать все метаданные
Копировать данные камеры
- Копировать координаты
Местоположение Фото
Нельзя очистить во время обработки.
Не удалось подготовить очищенный файл для отправки.
@@ -323,4 +322,9 @@
Транскодирование в целевой формат…
Ремультиплексирование видео…
Проверка удаления метаданных…
+ Открыть в Картах
+ Открыть местоположение в Картах?
+ Координаты откроются в приложении карт по умолчанию на вашем устройстве. В отличие от всех остальных действий на этом экране, ваше точное GPS-местоположение передаётся отдельному приложению, которое может отправить его за пределы устройства, например для загрузки фрагментов карты или поиска адреса. Сам Redact никогда никуда не отправляет эти данные.\n\nЭто сообщение появится только один раз. Ваш выбор продолжить запоминается, поэтому повторно запрашиваться не будет.
+ Открыть Карты
+ Нет приложения карт для открытия этого местоположения.
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index c300637..86d0d6b 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -269,7 +269,6 @@
已复制到剪贴板
复制全部元数据
复制相机信息
- 复制坐标
照片位置
处理进行中时无法清除。
无法准备已清理的文件以供分享。
@@ -323,4 +322,9 @@
正在转码为目标格式…
正在重新封装…
正在验证元数据是否已删除…
+ 在地图中打开
+ 在地图中打开位置?
+ 这会在您设备的默认地图应用中打开坐标。与此屏幕上的其他所有操作不同,这会将您的精确 GPS 位置交给另一个应用,该应用可能会将其发送到此设备之外,例如加载地图图块或查询地址。Redact 本身绝不会将这些数据发送到任何地方。\n\n此消息只会显示一次。您选择继续后会被记住,因此不会再次询问。
+ 打开地图
+ 没有可用于打开此位置的地图应用。
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index ae71d95..2f7ea3d 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -269,7 +269,6 @@
已複製到剪貼簿
複製全部中繼資料
複製相機資訊
- 複製座標
照片位置
處理進行中時無法清除。
無法準備已清理的檔案以供分享。
@@ -323,4 +322,9 @@
正在轉碼為目標格式…
正在重新封裝…
正在驗證中繼資料是否已移除…
+ 在地圖中開啟
+ 要在地圖中開啟位置嗎?
+ 這會在您裝置的預設地圖應用程式中開啟座標。與此畫面上的其他所有操作不同,這會將您的精確 GPS 位置交給另一個應用程式,該應用程式可能會將其傳送到此裝置之外,例如載入地圖圖磚或查詢地址。Redact 本身絕不會將這些資料傳送到任何地方。\n\n此訊息只會顯示一次。您選擇繼續後會被記住,因此不會再次詢問。
+ 開啟地圖
+ 沒有可用於開啟此位置的地圖應用程式。
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 0857ca9..714ba5a 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -8,5 +8,6 @@
@dimen/screen_edge_padding
@dimen/component_corner_large
+ 16dp
96dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 643c14b..5c492d2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -223,12 +223,16 @@
Detected fields from this file. Tap and hold values to copy.
GPS coordinates might be available if you grant the location permission.
We couldn\'t retrieve the metadata.
- Copy Coordinates
+ Open in Maps
Copy Camera Info
Copy All Metadata
Clean This File
Convert This File
Copied to clipboard
+ Open location in Maps?
+ This opens the coordinates in your device\'s default maps app. Unlike every other action on this screen, that hands your exact GPS location to a separate app, which may send it off this device, for example to load map tiles or look up the address. Redact itself never sends this data anywhere.\n\nYou\'ll only see this message once. Choosing to continue is remembered, so this won\'t ask again.
+ Open Maps
+ No maps app is available to open this location.
diff --git a/app/src/test/java/com/doubleangels/redact/AppPreferencesTest.java b/app/src/test/java/com/doubleangels/redact/AppPreferencesTest.java
index 633d3e5..bd3b65e 100644
--- a/app/src/test/java/com/doubleangels/redact/AppPreferencesTest.java
+++ b/app/src/test/java/com/doubleangels/redact/AppPreferencesTest.java
@@ -153,4 +153,13 @@ public void initialPermissionsPrompt_tracksCompletion() {
AppPreferences.setInitialPermissionsPromptCompleted(context);
assertTrue(AppPreferences.hasCompletedInitialPermissionsPrompt(context));
}
+
+ @Test
+ public void mapsLocationConsent_defaultsFalseAndIsSettable() {
+ assertFalse(AppPreferences.hasConsentedToOpenLocationInMaps(context));
+ AppPreferences.setConsentedToOpenLocationInMaps(context, true);
+ assertTrue(AppPreferences.hasConsentedToOpenLocationInMaps(context));
+ AppPreferences.setConsentedToOpenLocationInMaps(context, false);
+ assertFalse(AppPreferences.hasConsentedToOpenLocationInMaps(context));
+ }
}
\ No newline at end of file
diff --git a/app/src/test/java/com/doubleangels/redact/CacheCleanupTest.java b/app/src/test/java/com/doubleangels/redact/CacheCleanupTest.java
index 9933fd7..184f427 100644
--- a/app/src/test/java/com/doubleangels/redact/CacheCleanupTest.java
+++ b/app/src/test/java/com/doubleangels/redact/CacheCleanupTest.java
@@ -30,12 +30,15 @@ public void setUp() throws IOException {
context = RuntimeEnvironment.getApplication();
originalLocale = Locale.getDefault();
Locale.setDefault(Locale.US);
+ CacheCleanup.resetAutoCleanupStateForTests();
clearCacheTree();
+ AppPreferences.setAutoClearTempFiles(context, true);
}
@After
public void tearDown() {
Locale.setDefault(originalLocale);
+ CacheCleanup.resetAutoCleanupStateForTests();
}
private void clearCacheTree() {
@@ -137,6 +140,42 @@ public void clearAllTempFiles_usesShortGracePeriodAndCleansOld() throws IOExcept
assertTrue(justCreated.exists());
}
+ @Test
+ public void shouldRunAutoCleanup_trueOnFirstCallThenFalseForRestOfProcess() {
+ assertTrue(CacheCleanup.shouldRunAutoCleanup(context));
+ assertFalse(CacheCleanup.shouldRunAutoCleanup(context));
+ assertFalse(CacheCleanup.shouldRunAutoCleanup(context));
+ }
+
+ @Test
+ public void shouldRunAutoCleanup_falseWhenPreferenceDisabled() {
+ AppPreferences.setAutoClearTempFiles(context, false);
+ assertFalse(CacheCleanup.shouldRunAutoCleanup(context));
+ }
+
+ @Test
+ public void performAutoCleanup_removesStaleFilesLikeClearStaleTempFiles() throws IOException {
+ File cacheDir = context.getCacheDir();
+ long oldTimestamp =
+ System.currentTimeMillis() - CacheCleanup.DEFAULT_STALE_TEMP_MAX_AGE_MS * 2;
+ File oldTemp = writeFile(new File(cacheDir, "temp_old.bin"), 100);
+ oldTemp.setLastModified(oldTimestamp);
+
+ CacheCleanup.performAutoCleanup(context);
+
+ assertFalse(oldTemp.exists());
+ }
+
+ @Test
+ public void scheduleAutoCleanupIfEnabled_doesNotThrowWhenDisabled() {
+ // Kept off for this test so it never hands work to the real background executor --
+ // the actual sweep behavior is covered deterministically by performAutoCleanup and
+ // shouldRunAutoCleanup above, without a background thread that could outlive the test.
+ AppPreferences.setAutoClearTempFiles(context, false);
+ CacheCleanup.scheduleAutoCleanupIfEnabled(context);
+ CacheCleanup.scheduleAutoCleanupIfEnabled(context);
+ }
+
private static File writeFile(File file, int size) throws IOException {
file.getParentFile().mkdirs();
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(file)) {
diff --git a/app/src/test/java/com/doubleangels/redact/ShareHandlerActivityTest.java b/app/src/test/java/com/doubleangels/redact/ShareHandlerActivityTest.java
new file mode 100644
index 0000000..97b8a91
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/ShareHandlerActivityTest.java
@@ -0,0 +1,374 @@
+package com.doubleangels.redact;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeFalse;
+
+import android.app.Application;
+import android.content.ClipData;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Looper;
+
+import androidx.appcompat.app.AlertDialog;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.GraphicsMode;
+import org.robolectric.shadows.ShadowDialog;
+import org.robolectric.shadows.ShadowLog;
+import org.robolectric.shadows.ShadowToast;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 34)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+public class ShareHandlerActivityTest {
+
+ private static final long PROCESSING_TIMEOUT_MS = 20_000L;
+
+ private Application app;
+ private ActivityController controller;
+ private final List sourceFiles = new ArrayList<>();
+
+ @Before
+ public void setUp() {
+ app = RuntimeEnvironment.getApplication();
+ AppPreferences.setShareConfirmBeforeStrip(app, false);
+ deleteInboundSnapshots();
+ clearFileProviderCache();
+ }
+
+ /**
+ * FileProvider caches its path roots statically, but Robolectric gives each test a new
+ * data directory, so a cached strategy from an earlier test rejects this test's files.
+ */
+ private static void clearFileProviderCache() {
+ try {
+ java.lang.reflect.Field cache =
+ androidx.core.content.FileProvider.class.getDeclaredField("sCache");
+ cache.setAccessible(true);
+ Object map = cache.get(null);
+ synchronized (map) {
+ ((java.util.Map, ?>) map).clear();
+ }
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError("Could not reset FileProvider cache", e);
+ }
+ }
+
+ @After
+ public void tearDown() {
+ if (controller != null) {
+ controller.pause().stop().destroy();
+ }
+ for (File file : sourceFiles) {
+ file.delete();
+ }
+ deleteInboundSnapshots();
+ }
+
+ private ShareHandlerActivity launch(Intent intent) {
+ controller = Robolectric.buildActivity(ShareHandlerActivity.class, intent).setup();
+ return controller.get();
+ }
+
+ private static Intent sendIntent(String type, Uri stream) {
+ Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.setType(type);
+ if (stream != null) {
+ intent.putExtra(Intent.EXTRA_STREAM, stream);
+ }
+ return intent;
+ }
+
+ private static Intent sendMultipleIntent(String type, ArrayList streams) {
+ Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
+ intent.setType(type);
+ intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, streams);
+ return intent;
+ }
+
+ private Uri createJpeg() throws IOException {
+ File jpeg = File.createTempFile("share_src_", ".jpg", app.getFilesDir());
+ sourceFiles.add(jpeg);
+ Bitmap bitmap = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888);
+ bitmap.eraseColor(Color.BLUE);
+ try (FileOutputStream fos = new FileOutputStream(jpeg)) {
+ assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fos));
+ }
+ return Uri.fromFile(jpeg);
+ }
+
+ private File[] inboundSnapshots() {
+ File[] files = app.getCacheDir().listFiles((dir, name) -> name.startsWith("inbound_"));
+ return files != null ? files : new File[0];
+ }
+
+ private void deleteInboundSnapshots() {
+ for (File file : inboundSnapshots()) {
+ file.delete();
+ }
+ }
+
+ /**
+ * FileProvider matches roots with a hard-coded '/' separator, so producing the cleaned
+ * content URI only works under Robolectric on POSIX hosts (CI runs on Linux).
+ */
+ private static void assumeFileProviderUsable() {
+ assumeFalse("FileProvider path matching fails on Windows hosts",
+ System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).startsWith("windows"));
+ }
+
+ /** Error-level logcat captured during the test, to make CI failures diagnosable. */
+ private static String loggedErrors() {
+ StringBuilder sb = new StringBuilder();
+ for (ShadowLog.LogItem item : ShadowLog.getLogs()) {
+ if (item.type >= android.util.Log.WARN) {
+ sb.append(System.lineSeparator()).append(item.tag).append(": ").append(item.msg);
+ if (item.throwable != null) {
+ sb.append(" -> ").append(item.throwable);
+ }
+ }
+ }
+ return sb.toString();
+ }
+
+ private void assertFinishedWithError(ShareHandlerActivity activity, int messageRes) {
+ assertEquals(app.getString(messageRes), ShadowToast.getTextOfLatestToast());
+ assertTrue(activity.isFinishing());
+ }
+
+ private Intent awaitStartedActivity(ShareHandlerActivity activity) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + PROCESSING_TIMEOUT_MS;
+ while (System.currentTimeMillis() < deadline) {
+ Shadows.shadowOf(Looper.getMainLooper()).idle();
+ Intent started = Shadows.shadowOf(activity).peekNextStartedActivityForResult() != null
+ ? Shadows.shadowOf(activity).getNextStartedActivityForResult().intent
+ : null;
+ if (started != null || activity.isFinishing()) {
+ return started;
+ }
+ Thread.sleep(25);
+ }
+ return null;
+ }
+
+ @Test
+ public void unsupportedAction_finishesWithError() {
+ ShareHandlerActivity activity = launch(new Intent(Intent.ACTION_VIEW));
+ assertFinishedWithError(activity, R.string.share_error_unsupported_action);
+ }
+
+ @Test
+ public void send_unsupportedMimeType_finishesWithError() {
+ ShareHandlerActivity activity = launch(sendIntent("text/plain", Uri.parse("content://x/y")));
+ assertFinishedWithError(activity, R.string.share_error_unsupported_media);
+ }
+
+ @Test
+ public void sendMultiple_unsupportedMimeType_finishesWithError() {
+ ArrayList uris = new ArrayList<>();
+ uris.add(Uri.parse("content://x/y"));
+ ShareHandlerActivity activity = launch(sendMultipleIntent("application/pdf", uris));
+ assertFinishedWithError(activity, R.string.share_error_unsupported_media);
+ }
+
+ @Test
+ public void send_withoutStream_finishesWithReceiveError() {
+ ShareHandlerActivity activity = launch(sendIntent("image/jpeg", null));
+ assertFinishedWithError(activity, R.string.share_error_failed_receive_media);
+ }
+
+ @Test
+ public void send_withUnsupportedScheme_finishesWithReceiveError() {
+ ShareHandlerActivity activity =
+ launch(sendIntent("image/jpeg", Uri.parse("https://example.com/a.jpg")));
+ assertFinishedWithError(activity, R.string.share_error_failed_receive_media);
+ }
+
+ @Test
+ public void sendMultiple_emptyList_finishesWithReceiveError() {
+ ShareHandlerActivity activity = launch(sendMultipleIntent("image/*", new ArrayList<>()));
+ assertFinishedWithError(activity, R.string.share_error_failed_receive_media);
+ }
+
+ @Test
+ public void sendMultiple_tooManyItems_finishesWithLimitError() {
+ ArrayList uris = new ArrayList<>();
+ for (int i = 0; i < 21; i++) {
+ uris.add(Uri.parse("content://com.example/media/" + i));
+ }
+ ShareHandlerActivity activity = launch(sendMultipleIntent("image/*", uris));
+ assertEquals(app.getString(R.string.share_error_too_many_items, 20),
+ ShadowToast.getTextOfLatestToast());
+ assertTrue(activity.isFinishing());
+ assertEquals(0, inboundSnapshots().length);
+ }
+
+ @Test
+ public void send_withConfirmEnabled_snapshotsInputAndShowsConfirmDialog() throws IOException {
+ AppPreferences.setShareConfirmBeforeStrip(app, true);
+
+ ShareHandlerActivity activity = launch(sendIntent("image/jpeg", createJpeg()));
+
+ assertFalse(activity.isFinishing());
+ assertEquals(1, inboundSnapshots().length);
+ AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
+ assertNotNull(dialog);
+ assertTrue(dialog.isShowing());
+ assertFalse(ShareHandlerActivity.isShareProcessingActive());
+ }
+
+ @Test
+ public void send_deduplicatesStreamAndClipDataUris() throws IOException {
+ AppPreferences.setShareConfirmBeforeStrip(app, true);
+ Uri jpeg = createJpeg();
+ Intent intent = sendIntent("image/jpeg", jpeg);
+ intent.setClipData(ClipData.newRawUri("photo", jpeg));
+
+ launch(intent);
+
+ assertEquals(1, inboundSnapshots().length);
+ }
+
+ @Test
+ public void sendMultiple_snapshotsEachDistinctItem() throws IOException {
+ AppPreferences.setShareConfirmBeforeStrip(app, true);
+ ArrayList uris = new ArrayList<>();
+ uris.add(createJpeg());
+ uris.add(createJpeg());
+ uris.add(Uri.parse("https://example.com/ignored.jpg"));
+
+ launch(sendMultipleIntent("image/*", uris));
+
+ assertEquals(2, inboundSnapshots().length);
+ }
+
+ @Test
+ public void confirmDialogCancel_finishesAndDeletesSnapshots()
+ throws IOException, InterruptedException {
+ AppPreferences.setShareConfirmBeforeStrip(app, true);
+ ShareHandlerActivity activity = launch(sendIntent("image/jpeg", createJpeg()));
+ assertEquals(1, inboundSnapshots().length);
+
+ AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
+ dialog.getButton(DialogInterface.BUTTON_NEGATIVE).performClick();
+ Shadows.shadowOf(Looper.getMainLooper()).idle();
+
+ assertTrue(activity.isFinishing());
+ controller.pause().stop().destroy();
+ controller = null;
+ // Snapshot deletion runs on ShareHandlerActivity's background cleanup executor.
+ long deadline = System.currentTimeMillis() + 5000;
+ while (inboundSnapshots().length > 0 && System.currentTimeMillis() < deadline) {
+ Thread.sleep(25);
+ }
+ assertEquals(0, inboundSnapshots().length);
+ }
+
+ @Test
+ public void send_image_stripsAndLaunchesShareChooser() throws Exception {
+ assumeFileProviderUsable();
+ ShareHandlerActivity activity = launch(sendIntent("image/jpeg", createJpeg()));
+
+ Intent chooser = awaitStartedActivity(activity);
+
+ assertNotNull("Expected share chooser; toast was: " + ShadowToast.getTextOfLatestToast()
+ + loggedErrors(),
+ chooser);
+ assertEquals(Intent.ACTION_CHOOSER, chooser.getAction());
+ Intent share = chooser.getParcelableExtra(Intent.EXTRA_INTENT, Intent.class);
+ assertNotNull(share);
+ assertEquals(Intent.ACTION_SEND, share.getAction());
+ assertEquals("image/*", share.getType());
+ assertTrue((share.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0);
+ Uri cleaned = share.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
+ assertNotNull(cleaned);
+ assertEquals("content", cleaned.getScheme());
+ assertFalse(ShareHandlerActivity.isShareProcessingActive());
+ }
+
+ @Test
+ public void sendMultiple_images_launchesMultipleShareChooser() throws Exception {
+ assumeFileProviderUsable();
+ ArrayList uris = new ArrayList<>();
+ uris.add(createJpeg());
+ uris.add(createJpeg());
+ ShareHandlerActivity activity = launch(sendMultipleIntent("image/*", uris));
+
+ Intent chooser = awaitStartedActivity(activity);
+
+ assertNotNull("Expected share chooser; toast was: " + ShadowToast.getTextOfLatestToast()
+ + loggedErrors(),
+ chooser);
+ Intent share = chooser.getParcelableExtra(Intent.EXTRA_INTENT, Intent.class);
+ assertNotNull(share);
+ assertEquals(Intent.ACTION_SEND_MULTIPLE, share.getAction());
+ assertEquals("image/*", share.getType());
+ ArrayList cleaned = share.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
+ assertNotNull(cleaned);
+ assertEquals(2, cleaned.size());
+ assertEquals(2, share.getClipData().getItemCount());
+ }
+
+ @Test
+ public void send_undecodableImage_finishesWithProcessingError() throws Exception {
+ File garbage = File.createTempFile("share_bad_", ".jpg", app.getFilesDir());
+ sourceFiles.add(garbage);
+ try (FileOutputStream fos = new FileOutputStream(garbage)) {
+ fos.write("definitely not a jpeg".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
+ }
+ ShareHandlerActivity activity = launch(sendIntent("image/jpeg", Uri.fromFile(garbage)));
+
+ Intent started = awaitStartedActivity(activity);
+
+ assertNull("Unexpected share chooser" + loggedErrors(), started);
+ assertFinishedWithError(activity, R.string.share_error_processing_failed);
+ assertFalse(ShareHandlerActivity.isShareProcessingActive());
+ }
+
+ @Test
+ public void restoredWhileProcessing_finishesWithInterruptedError() {
+ Bundle saved = new Bundle();
+ saved.putBoolean("processing_active", true);
+ controller = Robolectric.buildActivity(ShareHandlerActivity.class,
+ sendIntent("image/jpeg", Uri.parse("content://x/y")));
+ controller.create(saved);
+
+ assertFinishedWithError(controller.get(), R.string.share_error_interrupted);
+ }
+
+ @Test
+ public void restoredAfterShareLaunched_finishesSilently() {
+ ShadowToast.reset();
+ Bundle saved = new Bundle();
+ saved.putBoolean("sharing_initiated", true);
+ controller = Robolectric.buildActivity(ShareHandlerActivity.class,
+ sendIntent("image/jpeg", Uri.parse("content://x/y")));
+ controller.create(saved);
+
+ assertTrue(controller.get().isFinishing());
+ assertNull(ShadowToast.getTextOfLatestToast());
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/StringTranslationsTest.java b/app/src/test/java/com/doubleangels/redact/StringTranslationsTest.java
new file mode 100644
index 0000000..a42a93d
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/StringTranslationsTest.java
@@ -0,0 +1,84 @@
+package com.doubleangels.redact;
+
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Fails the build if any translatable string is missing from, or malformed in, a locale. */
+public class StringTranslationsTest {
+
+ private static final File RES = new File("src/main/res");
+ private static final Pattern FORMAT = Pattern.compile("%(\\d+\\$)?[sdf]");
+
+ @Test
+ public void everyLocaleTranslatesEveryString() throws Exception {
+ Set base = names(new File(RES, "values/strings.xml"), true);
+ File[] dirs = RES.listFiles((d, n) -> n.startsWith("values-") && new File(d, n + "/strings.xml").exists());
+ assertTrue("No translations found", dirs != null && dirs.length > 0);
+ List problems = new ArrayList<>();
+ for (File dir : dirs) {
+ Set loc = names(new File(dir, "strings.xml"), false);
+ for (String k : base) if (!loc.contains(k)) problems.add(dir.getName() + " missing " + k);
+ for (String k : loc) if (!base.contains(k)) problems.add(dir.getName() + " extra " + k);
+ }
+ assertTrue(String.join("\n", problems), problems.isEmpty());
+ }
+
+ @Test
+ public void formatPlaceholdersMatchDefaultLocale() throws Exception {
+ List problems = new ArrayList<>();
+ java.util.Map> base = placeholders(new File(RES, "values/strings.xml"));
+ File[] dirs = RES.listFiles((d, n) -> n.startsWith("values-") && new File(d, n + "/strings.xml").exists());
+ for (File dir : dirs) {
+ for (java.util.Map.Entry> e : placeholders(new File(dir, "strings.xml")).entrySet()) {
+ Set want = base.get(e.getKey());
+ if (want != null && !want.equals(e.getValue()))
+ problems.add(dir.getName() + " " + e.getKey() + " placeholders " + e.getValue() + " != " + want);
+ }
+ }
+ assertTrue(String.join("\n", problems), problems.isEmpty());
+ }
+
+ private static NodeList strings(File f) throws Exception {
+ return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(f)
+ .getDocumentElement().getChildNodes();
+ }
+
+ /** Names of resources; for the default locale only translatable ones. */
+ private static Set names(File f, boolean translatableOnly) throws Exception {
+ Set out = new HashSet<>();
+ NodeList nl = strings(f);
+ for (int i = 0; i < nl.getLength(); i++) {
+ if (!(nl.item(i) instanceof Element)) continue;
+ Element e = (Element) nl.item(i);
+ if (translatableOnly && "false".equals(e.getAttribute("translatable"))) continue;
+ out.add(e.getAttribute("name"));
+ }
+ return out;
+ }
+
+ private static java.util.Map> placeholders(File f) throws Exception {
+ java.util.Map> out = new java.util.HashMap<>();
+ NodeList nl = strings(f);
+ for (int i = 0; i < nl.getLength(); i++) {
+ if (!(nl.item(i) instanceof Element)) continue;
+ Element e = (Element) nl.item(i);
+ Set s = new HashSet<>();
+ Matcher m = FORMAT.matcher(e.getTextContent());
+ while (m.find()) s.add(m.group().replaceFirst("^%1\\$", "%"));
+ out.put(e.getAttribute("name"), s);
+ }
+ return out;
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/media/AppProcessingScopeTest.java b/app/src/test/java/com/doubleangels/redact/media/AppProcessingScopeTest.java
new file mode 100644
index 0000000..f44b58a
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/media/AppProcessingScopeTest.java
@@ -0,0 +1,82 @@
+package com.doubleangels.redact.media;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 34)
+public class AppProcessingScopeTest {
+
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = RuntimeEnvironment.getApplication();
+ AppProcessingScope.resetForTests();
+ }
+
+ @After
+ public void tearDown() {
+ shutdown(AppProcessingScope.get(context));
+ AppProcessingScope.resetForTests();
+ }
+
+ private static void shutdown(AppProcessingScope scope) {
+ scope.mediaProcessor().shutdown();
+ scope.convertExecutor().shutdownNow();
+ }
+
+ @Test
+ public void get_returnsSingleton() {
+ AppProcessingScope first = AppProcessingScope.get(context);
+ assertSame(first, AppProcessingScope.get(context));
+ assertSame(first.mediaProcessor(), AppProcessingScope.get(context).mediaProcessor());
+ }
+
+ @Test
+ public void resetForTests_producesFreshInstance() {
+ AppProcessingScope first = AppProcessingScope.get(context);
+ shutdown(first);
+ AppProcessingScope.resetForTests();
+ assertNotSame(first, AppProcessingScope.get(context));
+ }
+
+ @Test
+ public void freshScope_isIdle() {
+ AppProcessingScope scope = AppProcessingScope.get(context);
+ assertFalse(scope.isCleanBusy());
+ assertFalse(scope.isConvertBusy());
+ assertEquals(0, scope.cleanGeneration().get());
+ assertEquals(0, scope.convertGeneration().get());
+ }
+
+ @Test
+ public void isConvertBusy_tracksInProgressFlag() {
+ AppProcessingScope scope = AppProcessingScope.get(context);
+ scope.convertInProgress().set(true);
+ assertTrue(scope.isConvertBusy());
+ scope.convertInProgress().set(false);
+ assertFalse(scope.isConvertBusy());
+ }
+
+ @Test
+ public void convertExecutor_runsOnNamedThread() throws Exception {
+ String name = AppProcessingScope.get(context).convertExecutor()
+ .submit(() -> Thread.currentThread().getName())
+ .get();
+ assertEquals("redact-convert", name);
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/media/MediaStoreWritesTest.java b/app/src/test/java/com/doubleangels/redact/media/MediaStoreWritesTest.java
new file mode 100644
index 0000000..f01450c
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/media/MediaStoreWritesTest.java
@@ -0,0 +1,30 @@
+package com.doubleangels.redact.media;
+
+import static org.junit.Assert.assertEquals;
+
+import android.content.ContentValues;
+import android.provider.MediaStore;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+public class MediaStoreWritesTest {
+
+ @Test
+ @Config(sdk = 34)
+ public void markPending_setsIsPendingOnQAndAbove() {
+ ContentValues values = new ContentValues();
+ MediaStoreWrites.markPending(values);
+ assertEquals(Integer.valueOf(1), values.getAsInteger(MediaStore.MediaColumns.IS_PENDING));
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void markPublished_ignoresNullUri() {
+ MediaStoreWrites.markPublished(RuntimeEnvironment.getApplication().getContentResolver(), null);
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/media/MediaUriResolverTest.java b/app/src/test/java/com/doubleangels/redact/media/MediaUriResolverTest.java
new file mode 100644
index 0000000..ac17718
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/media/MediaUriResolverTest.java
@@ -0,0 +1,91 @@
+package com.doubleangels.redact.media;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 34)
+public class MediaUriResolverTest {
+
+ private static final String PICKER_PREFIX =
+ "content://media/picker/0/com.android.providers.media.photopicker/media/";
+
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = RuntimeEnvironment.getApplication();
+ }
+
+ @Test
+ public void isPhotoPickerUri_detectsPickerPathSegment() {
+ assertTrue(MediaUriResolver.isPhotoPickerUri(Uri.parse(PICKER_PREFIX + "42")));
+ }
+
+ @Test
+ public void isPhotoPickerUri_detectsPickerAuthority() {
+ assertTrue(MediaUriResolver.isPhotoPickerUri(
+ Uri.parse("content://com.android.providers.media.photopicker/media/42")));
+ }
+
+ @Test
+ public void isPhotoPickerUri_rejectsNonPickerAndNonContentUris() {
+ assertFalse(MediaUriResolver.isPhotoPickerUri(null));
+ assertFalse(MediaUriResolver.isPhotoPickerUri(
+ Uri.parse("content://media/external/images/media/42")));
+ assertFalse(MediaUriResolver.isPhotoPickerUri(Uri.parse("file:///sdcard/picker/a.jpg")));
+ }
+
+ @Test
+ public void resolveToMediaStoreUri_returnsNonContentUriUnchanged() {
+ Uri file = Uri.parse("file:///data/local/tmp/photo.jpg");
+ assertEquals(file, MediaUriResolver.resolveToMediaStoreUri(context, file));
+ }
+
+ @Test
+ public void resolveToMediaStoreUri_keepsDocumentUriWhenMediaStoreLookupFails() {
+ Uri document = Uri.parse("content://com.example.docs/document/abc");
+ assertEquals(document, MediaUriResolver.resolveToMediaStoreUri(context, document));
+ }
+
+ @Test
+ public void resolveToMediaStoreUri_keepsPickerUriWhenNoMediaStoreRowMatches() {
+ Uri picker = Uri.parse(PICKER_PREFIX + "42");
+ assertEquals(picker, MediaUriResolver.resolveToMediaStoreUri(context, picker));
+ }
+
+ @Test
+ public void resolveToMediaStoreUri_keepsPickerUriWithNonNumericId() {
+ Uri picker = Uri.parse(PICKER_PREFIX + "abc");
+ assertEquals(picker, MediaUriResolver.resolveToMediaStoreUri(context, picker));
+ }
+
+ @Test
+ public void resolveForDisplayNameQuery_fallsBackToOriginalUri() {
+ Uri document = Uri.parse("content://com.example.docs/document/abc");
+ assertEquals(document, MediaUriResolver.resolveForDisplayNameQuery(context, document));
+ }
+
+ @Test
+ public void readDisplayName_usesLastPathSegmentForFileUri() {
+ assertEquals("photo.jpg",
+ MediaUriResolver.readDisplayName(context, Uri.parse("file:///data/local/tmp/photo.jpg")));
+ }
+
+ @Test
+ public void readDisplayName_fallsBackToLastSegmentWhenProviderMissing() {
+ assertEquals("clip.mp4", MediaUriResolver.readDisplayName(
+ context, Uri.parse("content://com.example.missing/files/clip.mp4")));
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/media/VideoMedia3ConverterFormatTest.java b/app/src/test/java/com/doubleangels/redact/media/VideoMedia3ConverterFormatTest.java
new file mode 100644
index 0000000..dd2c0e2
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/media/VideoMedia3ConverterFormatTest.java
@@ -0,0 +1,53 @@
+package com.doubleangels.redact.media;
+
+import static org.junit.Assert.assertEquals;
+
+import androidx.media3.common.MimeTypes;
+
+import com.doubleangels.redact.AppPreferences;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 34)
+public class VideoMedia3ConverterFormatTest {
+
+ @Test
+ public void videoMimeTypeForFormatIndex_mapsEachCodec() {
+ assertEquals(MimeTypes.VIDEO_H264,
+ VideoMedia3Converter.videoMimeTypeForFormatIndex(AppPreferences.FORMAT_INDEX_JPEG_H264));
+ assertEquals(MimeTypes.VIDEO_H265,
+ VideoMedia3Converter.videoMimeTypeForFormatIndex(AppPreferences.FORMAT_INDEX_PNG_H265));
+ assertEquals(MimeTypes.VIDEO_VP9,
+ VideoMedia3Converter.videoMimeTypeForFormatIndex(AppPreferences.FORMAT_INDEX_WEBP_VP9));
+ assertEquals(MimeTypes.VIDEO_AV1,
+ VideoMedia3Converter.videoMimeTypeForFormatIndex(AppPreferences.FORMAT_INDEX_HEIC_AV1));
+ }
+
+ @Test
+ public void videoMimeTypeForFormatIndex_defaultsToH264ForUnknownIndex() {
+ assertEquals(MimeTypes.VIDEO_H264, VideoMedia3Converter.videoMimeTypeForFormatIndex(-1));
+ assertEquals(MimeTypes.VIDEO_H264, VideoMedia3Converter.videoMimeTypeForFormatIndex(99));
+ }
+
+ @Test
+ public void extensionForFormatIndex_matchesContainer() {
+ assertEquals(".mp4", VideoMedia3Converter.extensionForFormatIndex(0));
+ assertEquals(".mp4", VideoMedia3Converter.extensionForFormatIndex(1));
+ assertEquals(".webm", VideoMedia3Converter.extensionForFormatIndex(2));
+ assertEquals(".mkv", VideoMedia3Converter.extensionForFormatIndex(3));
+ assertEquals(".mp4", VideoMedia3Converter.extensionForFormatIndex(42));
+ }
+
+ @Test
+ public void containerMimeForFormatIndex_matchesExtension() {
+ assertEquals("video/mp4", VideoMedia3Converter.containerMimeForFormatIndex(0));
+ assertEquals("video/mp4", VideoMedia3Converter.containerMimeForFormatIndex(1));
+ assertEquals("video/webm", VideoMedia3Converter.containerMimeForFormatIndex(2));
+ assertEquals("video/x-matroska", VideoMedia3Converter.containerMimeForFormatIndex(3));
+ assertEquals("video/mp4", VideoMedia3Converter.containerMimeForFormatIndex(-5));
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/metadata/MetadataDisplayerCoordinatesTest.java b/app/src/test/java/com/doubleangels/redact/metadata/MetadataDisplayerCoordinatesTest.java
new file mode 100644
index 0000000..7d06336
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/metadata/MetadataDisplayerCoordinatesTest.java
@@ -0,0 +1,165 @@
+package com.doubleangels.redact.metadata;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 34)
+public class MetadataDisplayerCoordinatesTest {
+
+ private static final char RECORD_SEP = '\u001e';
+ private static final char UNIT_SEP = '\u001f';
+ private static final double DELTA = 1e-4;
+
+ private static String record(String key, String value) {
+ return key + UNIT_SEP + value + RECORD_SEP;
+ }
+
+ @Test
+ public void isLocationMetadataKey_matchesGpsPrefixAndLocation() {
+ assertTrue(MetadataDisplayer.isLocationMetadataKey("GPS_LATITUDE"));
+ assertTrue(MetadataDisplayer.isLocationMetadataKey("gpsTimestamp"));
+ assertTrue(MetadataDisplayer.isLocationMetadataKey("location"));
+ assertFalse(MetadataDisplayer.isLocationMetadataKey("MAKE"));
+ assertFalse(MetadataDisplayer.isLocationMetadataKey("LOCATION_NAME"));
+ assertFalse(MetadataDisplayer.isLocationMetadataKey(null));
+ }
+
+ @Test
+ public void isUsableMapCoordinate_rejectsNullIslandAndOutOfRange() {
+ assertTrue(MetadataDisplayer.isUsableMapCoordinate(39.6594, -104.962));
+ assertTrue(MetadataDisplayer.isUsableMapCoordinate(-90, 180));
+ assertFalse(MetadataDisplayer.isUsableMapCoordinate(0, 0));
+ assertFalse(MetadataDisplayer.isUsableMapCoordinate(90.1, 10));
+ assertFalse(MetadataDisplayer.isUsableMapCoordinate(10, -180.1));
+ }
+
+ @Test
+ public void resolveMapCoordinates_returnsNullForMissingSections() {
+ assertNull(MetadataDisplayer.resolveMapCoordinates(null));
+ assertNull(MetadataDisplayer.resolveMapCoordinates(new HashMap<>()));
+ }
+
+ @Test
+ public void resolveMapCoordinates_readsSeparatedLocationSection() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION,
+ record("GPS_LATITUDE", "39.6594000") + record("GPS_LONGITUDE", "-104.9620000"));
+
+ double[] coords = MetadataDisplayer.resolveMapCoordinates(sections);
+
+ assertNotNull(coords);
+ assertArrayEquals(new double[]{39.6594, -104.962}, coords, DELTA);
+ }
+
+ @Test
+ public void resolveMapCoordinates_readsColonLinesCaseInsensitivelyWithCommaDecimals() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION,
+ "gps_latitude: 48,8584\nGPS_Longitude: 2,2945\n");
+
+ double[] coords = MetadataDisplayer.resolveMapCoordinates(sections);
+
+ assertNotNull(coords);
+ assertArrayEquals(new double[]{48.8584, 2.2945}, coords, DELTA);
+ }
+
+ @Test
+ public void resolveMapCoordinates_fallsBackToBasicInfoSection() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_BASIC_INFO,
+ record("FILE_NAME", "a.jpg") + record("GPS_LATITUDE", "-33.8568")
+ + record("GPS_LONGITUDE", "151.2153"));
+
+ double[] coords = MetadataDisplayer.resolveMapCoordinates(sections);
+
+ assertNotNull(coords);
+ assertArrayEquals(new double[]{-33.8568, 151.2153}, coords, DELTA);
+ }
+
+ @Test
+ public void resolveMapCoordinates_swapsLatLonWhenLatitudeOutOfRange() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION,
+ record("GPS_LATITUDE", "-104.962") + record("GPS_LONGITUDE", "39.6594"));
+
+ double[] coords = MetadataDisplayer.resolveMapCoordinates(sections);
+
+ assertNotNull(coords);
+ assertArrayEquals(new double[]{39.6594, -104.962}, coords, DELTA);
+ }
+
+ @Test
+ public void resolveMapCoordinates_rejectsZeroCoordinates() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION,
+ record("GPS_LATITUDE", "0.0") + record("GPS_LONGITUDE", "0.0"));
+
+ assertNull(MetadataDisplayer.resolveMapCoordinates(sections));
+ }
+
+ @Test
+ public void resolveMapCoordinates_rejectsUnparseableValues() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION,
+ record("GPS_LATITUDE", "north") + record("GPS_LONGITUDE", "east"));
+
+ assertNull(MetadataDisplayer.resolveMapCoordinates(sections));
+ }
+
+ @Test
+ public void resolveMapCoordinates_parsesIso6709VideoLocation() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION, record("LOCATION", "+39.6594-104.9620/"));
+
+ double[] coords = MetadataDisplayer.resolveMapCoordinates(sections);
+
+ assertNotNull(coords);
+ assertArrayEquals(new double[]{39.6594, -104.962}, coords, DELTA);
+ }
+
+ @Test
+ public void resolveMapCoordinates_parsesVideoLocationFromBasicInfoColonLine() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_BASIC_INFO, "LOCATION: -33.8568+151.2153");
+
+ double[] coords = MetadataDisplayer.resolveMapCoordinates(sections);
+
+ assertNotNull(coords);
+ assertArrayEquals(new double[]{-33.8568, 151.2153}, coords, DELTA);
+ }
+
+ @Test
+ public void resolveMapCoordinates_rejectsMalformedVideoLocation() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION, record("LOCATION", "+39.6594"));
+ assertNull(MetadataDisplayer.resolveMapCoordinates(sections));
+
+ sections.put(MetadataDisplayer.SECTION_LOCATION, record("LOCATION", "+0.0+0.0"));
+ assertNull(MetadataDisplayer.resolveMapCoordinates(sections));
+ }
+
+ @Test
+ public void resolveMapCoordinates_prefersGpsKeysOverLocationString() {
+ Map sections = new HashMap<>();
+ sections.put(MetadataDisplayer.SECTION_LOCATION,
+ record("GPS_LATITUDE", "10.5") + record("GPS_LONGITUDE", "20.5")
+ + record("LOCATION", "+39.6594-104.9620"));
+
+ double[] coords = MetadataDisplayer.resolveMapCoordinates(sections);
+
+ assertNotNull(coords);
+ assertArrayEquals(new double[]{10.5, 20.5}, coords, DELTA);
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/notifications/LocalNotificationsTest.java b/app/src/test/java/com/doubleangels/redact/notifications/LocalNotificationsTest.java
new file mode 100644
index 0000000..16e7c4c
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/notifications/LocalNotificationsTest.java
@@ -0,0 +1,164 @@
+package com.doubleangels.redact.notifications;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.Manifest;
+import android.app.Application;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.content.Context;
+
+import com.doubleangels.redact.AppPreferences;
+import com.doubleangels.redact.R;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowNotificationManager;
+
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 34)
+public class LocalNotificationsTest {
+
+ private Application app;
+ private NotificationManager nm;
+ private ShadowNotificationManager shadowNm;
+
+ @Before
+ public void setUp() {
+ app = RuntimeEnvironment.getApplication();
+ nm = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
+ shadowNm = Shadows.shadowOf(nm);
+ shadowNm.setNotificationsEnabled(true);
+ Shadows.shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
+ AppPreferences.setNotificationsEnabled(app, true);
+ AppPreferences.setCleanNotificationsEnabled(app, true);
+ AppPreferences.setConvertNotificationsEnabled(app, true);
+ }
+
+ @After
+ public void tearDown() {
+ LocalNotifications.testSdkIntOverride = null;
+ }
+
+ private String onlyNotificationText() {
+ List posted = shadowNm.getAllNotifications();
+ assertEquals(1, posted.size());
+ return String.valueOf(posted.get(0).extras.getCharSequence(Notification.EXTRA_TEXT));
+ }
+
+ @Test
+ public void ensureChannels_createsTasksChannel() {
+ nm.deleteNotificationChannel(LocalNotifications.CHANNEL_ID_TASKS);
+ LocalNotifications.ensureChannels(app);
+ assertNotNull(nm.getNotificationChannel(LocalNotifications.CHANNEL_ID_TASKS));
+ }
+
+ @Test
+ public void ensureChannels_skipsBeforeOreo() {
+ nm.deleteNotificationChannel(LocalNotifications.CHANNEL_ID_TASKS);
+ LocalNotifications.testSdkIntOverride = 25;
+ LocalNotifications.ensureChannels(app);
+ assertNull(nm.getNotificationChannel(LocalNotifications.CHANNEL_ID_TASKS));
+ }
+
+ @Test
+ public void canPostNotifications_requiresRuntimePermission() {
+ Shadows.shadowOf(app).denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
+ assertFalse(LocalNotifications.canPostNotifications(app));
+ assertFalse(LocalNotifications.canStartForegroundService(app));
+ }
+
+ @Test
+ public void canPostNotifications_respectsSystemToggle() {
+ shadowNm.setNotificationsEnabled(false);
+ assertFalse(LocalNotifications.canPostNotifications(app));
+ assertFalse(LocalNotifications.canStartForegroundService(app));
+ }
+
+ @Test
+ public void canPostNotifications_respectsAppPreference() {
+ AppPreferences.setNotificationsEnabled(app, false);
+ assertFalse(LocalNotifications.canPostNotifications(app));
+ assertTrue(LocalNotifications.canStartForegroundService(app));
+ }
+
+ @Test
+ public void canPostNotifications_trueWhenAllowed() {
+ assertTrue(LocalNotifications.canPostNotifications(app));
+ }
+
+ @Test
+ public void showCleanComplete_postsSuccessText() {
+ LocalNotifications.showCleanComplete(app, 3, 0);
+ assertEquals(app.getString(R.string.notification_clean_body, 3), onlyNotificationText());
+ }
+
+ @Test
+ public void showCleanComplete_postsPartialText() {
+ LocalNotifications.showCleanComplete(app, 2, 1);
+ assertEquals(app.getString(R.string.notification_clean_partial, 2, 1), onlyNotificationText());
+ }
+
+ @Test
+ public void showCleanComplete_postsFailureText() {
+ LocalNotifications.showCleanComplete(app, 0, 4);
+ assertEquals(app.getString(R.string.notification_clean_failed), onlyNotificationText());
+ }
+
+ @Test
+ public void showCleanComplete_suppressedWhenCleanNotificationsDisabled() {
+ AppPreferences.setCleanNotificationsEnabled(app, false);
+ LocalNotifications.showCleanComplete(app, 1, 0);
+ assertEquals(0, shadowNm.size());
+ }
+
+ @Test
+ public void showConversionComplete_postsSuccessText() {
+ LocalNotifications.showConversionComplete(app, 5, 0);
+ assertEquals(app.getString(R.string.notification_convert_all_succeeded, 5), onlyNotificationText());
+ }
+
+ @Test
+ public void showConversionComplete_postsPartialText() {
+ LocalNotifications.showConversionComplete(app, 1, 2);
+ assertEquals(app.getString(R.string.notification_convert_partial, 1, 2), onlyNotificationText());
+ }
+
+ @Test
+ public void showConversionComplete_postsFailureText() {
+ LocalNotifications.showConversionComplete(app, 0, 0);
+ assertEquals(app.getString(R.string.notification_convert_failed), onlyNotificationText());
+ }
+
+ @Test
+ public void showConversionComplete_suppressedWhenMasterToggleOff() {
+ AppPreferences.setNotificationsEnabled(app, false);
+ LocalNotifications.showConversionComplete(app, 1, 0);
+ assertEquals(0, shadowNm.size());
+ }
+
+ @Test
+ public void cancelConvertProgress_removesConversionNotification() {
+ LocalNotifications.showConversionComplete(app, 1, 0);
+ assertEquals(1, shadowNm.size());
+ LocalNotifications.cancelConvertProgress(app);
+ assertEquals(0, shadowNm.size());
+ }
+
+ @Test
+ public void mainContentIntent_isNotNull() {
+ assertNotNull(LocalNotifications.mainContentIntent(app));
+ }
+}
diff --git a/app/src/test/java/com/doubleangels/redact/permission/PermissionManagerTest.java b/app/src/test/java/com/doubleangels/redact/permission/PermissionManagerTest.java
new file mode 100644
index 0000000..2ead13a
--- /dev/null
+++ b/app/src/test/java/com/doubleangels/redact/permission/PermissionManagerTest.java
@@ -0,0 +1,203 @@
+package com.doubleangels.redact.permission;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.pm.PackageManager;
+
+import com.doubleangels.redact.AppPreferences;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@RunWith(RobolectricTestRunner.class)
+public class PermissionManagerTest {
+
+ private Activity activity;
+
+ @Before
+ public void setUp() {
+ PermissionManager.resetRuntimePermissionRequestStateForTests();
+ activity = Robolectric.buildActivity(Activity.class).setup().get();
+ }
+
+ @After
+ public void tearDown() {
+ PermissionManager.resetRuntimePermissionRequestStateForTests();
+ }
+
+ private void grant(String... permissions) {
+ Shadows.shadowOf(activity.getApplication()).grantPermissions(permissions);
+ }
+
+ private void deny(String... permissions) {
+ Shadows.shadowOf(activity.getApplication()).denyPermissions(permissions);
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void collectMissing_sdk34_includesAllVisualMediaPermissions() {
+ deny(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED);
+
+ assertEquals(Arrays.asList(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED),
+ PermissionManager.collectMissingRuntimePermissions(activity));
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void collectMissing_sdk34_omitsGrantedPermissions() {
+ deny(Manifest.permission.READ_MEDIA_VIDEO);
+ grant(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED);
+
+ assertEquals(Collections.singletonList(Manifest.permission.READ_MEDIA_VIDEO),
+ PermissionManager.collectMissingRuntimePermissions(activity));
+ }
+
+ @Test
+ @Config(sdk = 33)
+ public void collectMissing_sdk33_requestsImagesAndVideoOnly() {
+ deny(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO);
+
+ assertEquals(Arrays.asList(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO),
+ PermissionManager.collectMissingRuntimePermissions(activity));
+ }
+
+ @Test
+ @Config(sdk = 31)
+ public void collectMissing_legacy_requestsExternalStorage() {
+ deny(Manifest.permission.READ_EXTERNAL_STORAGE);
+
+ assertEquals(Collections.singletonList(Manifest.permission.READ_EXTERNAL_STORAGE),
+ PermissionManager.collectMissingRuntimePermissions(activity));
+ }
+
+ @Test
+ @Config(sdk = 31)
+ public void collectMissing_legacy_emptyWhenGranted() {
+ grant(Manifest.permission.READ_EXTERNAL_STORAGE);
+
+ assertTrue(PermissionManager.collectMissingRuntimePermissions(activity).isEmpty());
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void prepareInitialPermissionFlow_marksFlowInProgressAndDefersChecks() {
+ AppPreferences.setInitialPermissionsPromptCompleted(activity);
+ assertFalse(PermissionManager.isInitialPermissionFlowInProgress());
+ assertFalse(PermissionManager.shouldDeferPermissionChecks(activity));
+
+ PermissionManager.prepareInitialPermissionFlow();
+
+ assertTrue(PermissionManager.isInitialPermissionFlowInProgress());
+ assertTrue(PermissionManager.shouldDeferPermissionChecks(activity));
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void shouldDeferPermissionChecks_trueUntilInitialPromptCompleted() {
+ assertTrue(PermissionManager.shouldDeferPermissionChecks(activity));
+ assertTrue(PermissionManager.shouldPromptInitialPermissions(activity));
+
+ AppPreferences.setInitialPermissionsPromptCompleted(activity);
+ assertFalse(PermissionManager.shouldDeferPermissionChecks(activity));
+ assertFalse(PermissionManager.shouldPromptInitialPermissions(activity));
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void handleInitialPermissionFlowResult_ignoredWhenFlowNotRunning() {
+ assertFalse(PermissionManager.handleInitialPermissionFlowResult(activity,
+ PermissionManager.STORAGE_PERMISSION_REQUEST_CODE,
+ new String[]{Manifest.permission.READ_MEDIA_IMAGES},
+ new int[]{PackageManager.PERMISSION_GRANTED}));
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void handleInitialPermissionFlowResult_ignoresUnrelatedRequestCode() {
+ PermissionManager.prepareInitialPermissionFlow();
+ assertFalse(PermissionManager.handleInitialPermissionFlowResult(activity, 0xBEEF,
+ new String[0], new int[0]));
+ assertTrue(PermissionManager.isInitialPermissionFlowInProgress());
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void initialFlow_completesImmediatelyWhenEverythingGranted() {
+ grant(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
+ Manifest.permission.ACCESS_MEDIA_LOCATION, Manifest.permission.POST_NOTIFICATIONS);
+ AtomicBoolean completed = new AtomicBoolean(false);
+ PermissionManager.setInitialFlowCompletedCallback(() -> completed.set(true));
+
+ PermissionManager.prepareInitialPermissionFlow();
+ PermissionManager.startInitialPermissionFlow(activity);
+ ShadowLooper.idleMainLooper();
+
+ assertFalse(PermissionManager.isInitialPermissionFlowInProgress());
+ assertTrue(AppPreferences.hasCompletedInitialPermissionsPrompt(activity));
+ assertTrue(completed.get());
+ assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void initialFlow_requestsMissingMediaPermissionsFirst() {
+ deny(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED);
+
+ PermissionManager.prepareInitialPermissionFlow();
+ PermissionManager.startInitialPermissionFlow(activity);
+
+ List requested = Arrays.asList(
+ Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions);
+ assertTrue(requested.contains(Manifest.permission.READ_MEDIA_IMAGES));
+ assertTrue(PermissionManager.isInitialPermissionFlowInProgress());
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void requestMissingRuntimePermissions_includesMediaLocationWhenAsked() {
+ grant(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED);
+ deny(Manifest.permission.ACCESS_MEDIA_LOCATION);
+
+ PermissionManager.requestMissingRuntimePermissions(activity, true);
+
+ assertEquals(Collections.singletonList(Manifest.permission.ACCESS_MEDIA_LOCATION),
+ Arrays.asList(Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions));
+ }
+
+ @Test
+ @Config(sdk = 34)
+ public void requestMissingRuntimePermissions_noRequestWhenNothingMissing() {
+ grant(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED);
+
+ PermissionManager.requestMissingRuntimePermissions(activity);
+
+ assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
+ }
+}