Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion app/src/main/java/com/httrack/android/HTTrackActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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) -> {
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions app/src/main/java/com/httrack/android/StoragePaths.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions app/src/test/java/com/httrack/android/LegacyMirrorImportTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;"));
}
}
65 changes: 65 additions & 0 deletions app/src/test/java/com/httrack/android/StoragePathsTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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));
}
}
Loading