diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index a5c8833..9c341a2 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -143,6 +143,9 @@ public class HTTrackActivity extends FragmentActivity { protected static final String NOTIFY_ASKED_NAME = "NotificationPermissionAsked"; // Whether the one-time "import your old mirrors" offer has been shown and dismissed for good. protected static final String IMPORT_OFFERED_NAME = "LegacyImportOffered"; + + /* Access as of the last resume, so a grant can be told from a plain return to the app. */ + private boolean hadStorageAccess; // Whether all-files storage access was ever offered; a refusal keeps mirrors in private storage. protected static final String STORAGE_ASKED_NAME = "StorageAccessAsked"; // The root we left on the last move: nothing is migrated, so the older projects are still there. @@ -800,7 +803,9 @@ protected void onCreate(final Bundle savedInstanceState) { } // First launch only, so a rotation does not bring the offer back; and not stacked on top - // of the native-load failure dialog. + // of the native-load failure dialog. Silent unless mirrors are already visible; onResume() + // asks again once a storage grant makes them so. + hadStorageAccess = hasAllFilesAccess(); if (savedInstanceState == null && HTTrackLib.loadedSuccessfully()) { offerLegacyMirrorImportOnce(); } @@ -2790,6 +2795,10 @@ private void offerLegacyMirrorImportOnce() { if (settings.getBoolean(IMPORT_OFFERED_NAME, false)) { return; } + // Null both without access and with nothing to import; either way, stay silent. + if (StoragePaths.legacyMirrorRoot(sharedStorageRoot()) == null) { + return; + } new AlertDialog.Builder(this) .setMessage(R.string.import_mirrors_offer) .setPositiveButton(R.string.import_mirrors_offer_yes, (dialog, which) -> { @@ -3352,6 +3361,13 @@ protected void onResume() { } // A grant made on the settings screen flips the button/warning off (no-op off this panel). refreshStorageAccessHints(); + // Ask again once a grant may have surfaced the folder; not mid-crawl, and not on a plain + // resume, which onCreate has already covered. + final boolean access = hasAllFilesAccess(); + if (runner == null && StoragePaths.accessJustAppeared(hadStorageAccess, access)) { + offerLegacyMirrorImportOnce(); + } + hadStorageAccess = access; } @Override diff --git a/app/src/main/java/com/httrack/android/StoragePaths.java b/app/src/main/java/com/httrack/android/StoragePaths.java index 4865a22..b3ce7cb 100644 --- a/app/src/main/java/com/httrack/android/StoragePaths.java +++ b/app/src/main/java/com/httrack/android/StoragePaths.java @@ -77,6 +77,19 @@ static File defaultRoot(final File external, final File internal, final File sha return new File(external != null ? external : internal, "Websites"); } + /** + * Whether a resume is the moment storage access appeared, which is the only resume that can + * newly reveal anything. Every other one must stay silent rather than re-ask. + * + * @param hadAccess + * access as it stood at the previous check + * @param hasAccess + * access as it stands now + */ + static boolean accessJustAppeared(final boolean hadAccess, final boolean hasAccess) { + return hasAccess && !hadAccess; + } + /** * Whether the freshly resolved root differs from the one in use, the first resolution counting * as a move. Gates the work that must happen once per move, not once per resume. @@ -155,6 +168,33 @@ static File resolveRoot(final File base, final Boolean writable, final File defa return defaultRoot; } + /** + * Mirrors left by builds before versionCode 61, which wrote under Download/ rather than the + * shared HTTrack/ root every build since uses. Returns the folder only when it holds a project + * with something in it, so a caller can stay silent rather than ask about nothing. + * + * @param shared + * the shared storage root, null when we have no access to look + * @return the legacy folder, or null when there is nothing to offer + */ + static File legacyMirrorRoot(final File shared) { + if (shared == null) { + return null; + } + final File legacy = new File(new File(new File(shared, "Download"), "HTTrack"), "Websites"); + final File[] entries = legacy.listFiles(); + if (entries == null) { + return null; + } + for (final File entry : entries) { + final String[] project = entry.list(); + if (project != null && project.length != 0) { + return legacy; + } + } + return null; + } + /** * Documents-provider id ("primary:<relative>") to open {@code dir} in the system Files app via * ACTION_VIEW, or null when it lies outside the primary shared volume or inside the Android/ subtree diff --git a/app/src/test/java/com/httrack/android/LegacyMirrorImportTest.java b/app/src/test/java/com/httrack/android/LegacyMirrorImportTest.java index 1ccbe5d..2b4ed91 100644 --- a/app/src/test/java/com/httrack/android/LegacyMirrorImportTest.java +++ b/app/src/test/java/com/httrack/android/LegacyMirrorImportTest.java @@ -370,4 +370,29 @@ public void aByteForByteCopyMatchesBinaryContent() throws Exception { assertArrayEquals(raw, Files.readAllBytes(new File(dest, "blob.bin").toPath())); } + + /** + * Without access the shared root is null, so the detector cannot look and the offer must not + * appear. Only the wiring can say that; the detector alone cannot tell "no access" from "empty". + */ + @Test + public void findingNothingReturnsBeforeTheDialog() throws IOException { + final String body = TestSources.between(TestSources.javaSource("HTTrackActivity"), + "private void offerLegacyMirrorImportOnce", "new AlertDialog.Builder"); + // One pattern, so the test cannot be satisfied by a stray "== null" and the pref check's + // own "return;" the way two independent substrings could. + assertTrue("finding nothing must return before the dialog is built", body.matches( + "(?s).*StoragePaths\\.legacyMirrorRoot\\(sharedStorageRoot\\(\\)\\)\\s*==\\s*null\\)\\s*\\{\\s*return;.*")); + } + + /** The resume that brings access is the only one that can reveal the folder. */ + @Test + public void theResumeOfferIsGatedOnTheGrant() throws IOException { + final String body = TestSources.between(TestSources.javaSource("HTTrackActivity"), + "protected void onResume", "\n }"); + assertTrue("a plain resume must not re-ask", body.matches( + "(?s).*StoragePaths\\.accessJustAppeared\\([^)]*\\)\\)\\s*\\{\\s*offerLegacyMirrorImportOnce\\(\\);.*")); + assertTrue("the next resume must compare against this one", + body.contains("hadStorageAccess = access;")); + } } diff --git a/app/src/test/java/com/httrack/android/StoragePathsTest.java b/app/src/test/java/com/httrack/android/StoragePathsTest.java index 7c27398..5be20fe 100644 --- a/app/src/test/java/com/httrack/android/StoragePathsTest.java +++ b/app/src/test/java/com/httrack/android/StoragePathsTest.java @@ -1,10 +1,12 @@ package com.httrack.android; 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 java.io.File; +import java.io.IOException; import java.nio.file.Files; import org.junit.Before; @@ -129,4 +131,67 @@ public void refusesAPathOutsideTheSharedRootOrWithoutIt() throws Exception { assertNull(StoragePaths.externalStorageDocId(shared, shared)); // the root itself, no sub-path assertNull(StoragePaths.externalStorageDocId(new File(shared, "HTTrack"), null)); } + + /** The path builds before versionCode 61 wrote to, spelled out so a change to it fails here. */ + private static File legacyTree(final File shared) throws IOException { + final File websites = new File(new File(new File(shared, "Download"), "HTTrack"), "Websites"); + assertTrue(websites.mkdirs()); + return websites; + } + + private static void project(final File websites, final String name) throws IOException { + final File dir = new File(websites, name); + assertTrue(dir.mkdir()); + assertTrue(new File(dir, "index.html").createNewFile()); + } + + /** A question the user cannot answer is worse than no question, so silence is the default. */ + @Test + public void anAbsentLegacyFolderIsNotOffered() throws IOException { + assertNull(StoragePaths.legacyMirrorRoot(tmp.newFolder("bare"))); + } + + /** Builds before versionCode 61 wrote here; an empty folder is not a reason to ask. */ + @Test + public void anEmptyLegacyFolderIsNotOffered() throws IOException { + final File shared = tmp.newFolder("empty"); + legacyTree(shared); + assertNull(StoragePaths.legacyMirrorRoot(shared)); + } + + /** An empty project would import nothing, so it is not worth a question either. */ + @Test + public void anEmptyProjectIsNotOffered() throws IOException { + final File shared = tmp.newFolder("hollow"); + assertTrue(new File(legacyTree(shared), "someproject").mkdir()); + assertNull(StoragePaths.legacyMirrorRoot(shared)); + } + + /** A stray file is not a project; only a project directory earns the question. */ + @Test + public void aFileIsNotAProject() throws IOException { + final File shared = tmp.newFolder("stray"); + assertTrue(new File(legacyTree(shared), "notes.txt").createNewFile()); + assertNull(StoragePaths.legacyMirrorRoot(shared)); + } + + /** The one test that pins the path itself: every negative case passes on a wrong path too. */ + @Test + public void aLegacyProjectIsOffered() throws IOException { + final File shared = tmp.newFolder("real"); + final File websites = legacyTree(shared); + project(websites, "someproject"); + assertEquals(websites, StoragePaths.legacyMirrorRoot(shared)); + } + + /** Only the resume that brings access can reveal anything; every other must stay silent. */ + @Test + public void onlyTheGrantItselfRaisesTheOffer() { + assertTrue(StoragePaths.accessJustAppeared(false, true)); + assertFalse("a plain resume with access must not re-ask", + StoragePaths.accessJustAppeared(true, true)); + assertFalse(StoragePaths.accessJustAppeared(false, false)); + assertFalse("access going away is not an invitation", + StoragePaths.accessJustAppeared(true, false)); + } }