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
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dependencies {

implementation 'androidx.core:core:1.13.1'
implementation 'androidx.fragment:fragment:1.8.2'
implementation 'androidx.documentfile:documentfile:1.0.1'

testImplementation 'junit:junit:4.13.2'
}
137 changes: 137 additions & 0 deletions app/src/main/java/com/httrack/android/HTTrackActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
Expand All @@ -66,6 +67,7 @@
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Parcelable;
import android.os.StrictMode;
import android.text.Editable;
Expand All @@ -91,6 +93,7 @@

import androidx.activity.OnBackPressedCallback;
import androidx.core.app.ActivityCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.core.app.NotificationChannelCompat;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
Expand Down Expand Up @@ -124,6 +127,8 @@ public class HTTrackActivity extends FragmentActivity {
// Whether POST_NOTIFICATIONS was ever asked for. Has to outlive the activity: pane_id is
// restored on rotation, and a second refusal is the one that sticks for good.
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";

// <br /> Pattern
protected static final Pattern brHtmlPattern = Pattern.compile(Pattern
Expand Down Expand Up @@ -161,6 +166,7 @@ protected static class VERSION_CODES {
protected static final int ACTIVITY_FILE_CHOOSER = 1;
protected static final int ACTIVITY_PROJECT_NAME_CHOOSER = 2;
protected static final int ACTIVITY_CLEANUP = 3;
protected static final int ACTIVITY_IMPORT_TREE = 4;

// Process unique session ID for the fragment identifier
protected String sessionID = "runner_task" + "_"
Expand Down Expand Up @@ -551,6 +557,12 @@ protected void onCreate(final Bundle savedInstanceState) {
if (extras != null) {
restoreInstanceState(extras);
}

// First launch only, so a rotation does not bring the offer back; and not stacked on top
// of the native-load failure dialog.
if (savedInstanceState == null && HTTrackLib.loadedSuccessfully()) {
offerLegacyMirrorImportOnce();
}
}

/* Install/Update time. */
Expand Down Expand Up @@ -2222,6 +2234,123 @@ public void onClickPrevious(final View view) {
}
}

/**
* Let the user pick a legacy mirror folder to copy in. The storage-access framework is the
* only way left to reach the old public location once we no longer hold storage permissions.
*/
private void startLegacyMirrorImport() {
final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
try {
showNotification(getString(R.string.import_mirrors_prompt));
startActivityForResult(intent, ACTIVITY_IMPORT_TREE);
} catch (final ActivityNotFoundException e) {
Log.w(getClass().getSimpleName(), "no document-tree picker", e);
showNotification(getString(R.string.import_mirrors_none));
}
}

private volatile boolean importInProgress;

/**
* Copy the picked tree into our Websites directory, entirely off the UI thread: even walking
* the tree to find "Websites" is one IPC per entry, too much for the main thread. The source
* is only read, never changed. Guarded against a second run while one is going, whose two
* threads would race on the same temp files.
*/
private void importMirrorsFrom(final Uri treeUri) {
if (importInProgress) {
showNotification(getString(R.string.import_mirrors_running));
return;
}
final ContentResolver resolver = getContentResolver();
resolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
final Context appContext = getApplicationContext();
final File dest = getProjectRootFile();
importInProgress = true;
showNotification(getString(R.string.import_mirrors_running));
new Thread(new Runnable() {
@Override
public void run() {
String outcome;
try {
outcome = runImport(appContext, resolver, treeUri, dest);
} finally {
importInProgress = false;
}
deliverImportOutcome(appContext, outcome);
}
}, "legacy-import").start();
}

/** The copy itself, on the worker thread; returns the message to show. **/
private String runImport(final Context context, final ContentResolver resolver,
final Uri treeUri, final File dest) {
DocumentFile root = DocumentFile.fromTreeUri(context, treeUri);
if (root == null) {
return getString(R.string.import_mirrors_none);
}
// A picked "HTTrack" folder holds "Websites"; descend so project dirs keep their depth.
final DocumentFile websites = root.findFile("Websites");
if (websites != null && websites.isDirectory()) {
root = websites;
}
final LegacyMirrorImport.Source source =
new LegacyMirrorImport.DocumentFileSource(resolver, root);
if (LegacyMirrorImport.totalSize(source) > dest.getUsableSpace()) {
return getString(R.string.import_mirrors_no_space);
}
final LegacyMirrorImport.Result result = LegacyMirrorImport.copyTree(source, dest);
if (result.firstError() != null) {
Log.w(getClass().getSimpleName(), "import: " + result.firstError());
}
if (!result.isComplete()) {
return getString(R.string.import_mirrors_partial, result.copied, result.failed);
}
if (result.copied == 0 && result.skipped == 0) {
return getString(R.string.import_mirrors_none);
}
return getString(R.string.import_mirrors_done, result.copied, result.skipped);
}

/**
* Report the outcome on the main thread. Uses the application context and its own handler so
* the toast still shows if the activity was rotated away during a long copy; the project list
* is refreshed only when an activity is still around to hold it.
*/
private void deliverImportOutcome(final Context appContext, final String message) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
Toast.makeText(appContext, message, Toast.LENGTH_LONG).show();
if (!isFinishing() && !isDestroyed()) {
refreshprojectNameSuggests();
}
}
});
}

/**
* Once per install, point users at the import. No storage permission remains to detect the
* old folder, so this offers rather than asserts; "Not now" leaves it to ask again.
*/
private void offerLegacyMirrorImportOnce() {
final SharedPreferences settings = getSharedPreferences(PREFS_NAME, 0);
if (settings.getBoolean(IMPORT_OFFERED_NAME, false)) {
return;
}
new AlertDialog.Builder(this)
.setMessage(R.string.import_mirrors_offer)
.setPositiveButton(R.string.import_mirrors_offer_yes, (dialog, which) -> {
settings.edit().putBoolean(IMPORT_OFFERED_NAME, true).apply();
startLegacyMirrorImport();
})
.setNegativeButton(R.string.import_mirrors_offer_later, null)
.setNeutralButton(R.string.import_mirrors_offer_never, (dialog, which) ->
settings.edit().putBoolean(IMPORT_OFFERED_NAME, true).apply())
.show();
}

/**
* Change base path
*/
Expand Down Expand Up @@ -2318,6 +2447,11 @@ protected void onActivityResult(final int requestCode, final int resultCode,
setBasePath(path);
}
break;
case ACTIVITY_IMPORT_TREE:
if (resultCode == Activity.RESULT_OK && data != null && data.getData() != null) {
importMirrorsFrom(data.getData());
}
break;
case ACTIVITY_PROJECT_NAME_CHOOSER:
if (resultCode == Activity.RESULT_OK) {
final String projectName = data
Expand Down Expand Up @@ -2577,6 +2711,9 @@ public boolean onOptionsItemSelected(final MenuItem item) {
case R.id.action_help:
browse(new File(new File(getResourceFile(), "html"), "index.html"));
break;
case R.id.action_import_mirrors:
startLegacyMirrorImport();
break;
default:
return super.onOptionsItemSelected(item);
}
Expand Down
189 changes: 189 additions & 0 deletions app/src/main/java/com/httrack/android/LegacyMirrorImport.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package com.httrack.android;

import android.content.ContentResolver;
import android.net.Uri;

import androidx.documentfile.provider.DocumentFile;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;

/**
* One-time copy of mirrors from a user-picked legacy location into the app-specific directory.
*
* Scoped storage put the old public HTTrack/Websites tree out of reach, so the user grants it
* once through the storage-access framework and its contents are copied here. The copy never
* touches the source, so a failed or interrupted run loses nothing and can simply be re-run:
* existing destination files are skipped, which also makes it idempotent.
*/
final class LegacyMirrorImport {
private static final int BUFFER = 64 * 1024;

private LegacyMirrorImport() {
}

/** A node of the tree being copied, so the walk can be tested without a real DocumentFile. */
interface Source {
String getName();

boolean isDirectory();

long length();

List<Source> children();

InputStream openStream() throws IOException;
}

// Cap the retained messages: a broken tree could otherwise produce one per file.
private static final int MAX_ERRORS = 20;

/** Tally of a copy run; a per-file failure is recorded, not thrown, so the rest still copies. */
static final class Result {
int copied;
int skipped;
int failed;
// Set by the caller when the tree does not fit; nothing is copied in that case.
boolean outOfSpace;
final List<String> errors = new ArrayList<String>();

boolean isComplete() {
return failed == 0 && !outOfSpace;
}

String firstError() {
return errors.isEmpty() ? null : errors.get(0);
}
}

/**
* Copy {@code src}'s contents into {@code destDir}, recursively. Files already present at the
* destination are left as they are. Never modifies the source.
*
* @return the tally; {@link Result#isComplete()} is false if any file could not be copied
*/
static Result copyTree(final Source src, final File destDir) {
final Result result = new Result();
copyInto(src, destDir, result);
return result;
}

private static void copyInto(final Source dir, final File destDir, final Result result) {
if (!destDir.exists() && !destDir.mkdirs()) {
record(result, "could not create " + destDir);
return;
}
for (final Source child : dir.children()) {
final String name = child.getName();
if (name == null || name.isEmpty() || name.equals(".") || name.equals("..")
|| name.indexOf('/') >= 0) {
record(result, "unsafe entry name '" + name + "'");
continue;
}
final File dest = new File(destDir, name);
if (child.isDirectory()) {
copyInto(child, dest, result);
} else if (dest.isDirectory()) {
// A directory already sits where this file would go; skipping would drop it silently.
record(result, "name clash at " + dest);
} else if (dest.exists()) {
result.skipped++;
} else {
copyFile(child, dest, result);
}
}
}

private static void copyFile(final Source src, final File dest, final Result result) {
// A partial file from an interrupted run must not look complete on the next pass; write to a
// temp sibling and rename only once whole, so only finished files ever satisfy dest.exists().
final File tmp = new File(dest.getParentFile(), dest.getName() + ".part");
try (final InputStream in = src.openStream();
final OutputStream out = new FileOutputStream(tmp)) {
final byte[] buffer = new byte[BUFFER];
int n;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
}
} catch (final IOException e) {
tmp.delete();
record(result, "could not copy " + src.getName() + ": " + e.getMessage());
return;
}
if (tmp.renameTo(dest)) {
result.copied++;
} else {
tmp.delete();
record(result, "could not finalize " + dest);
}
}

private static void record(final Result result, final String message) {
result.failed++;
if (result.errors.size() < MAX_ERRORS) {
result.errors.add(message);
}
}

/** Bytes the tree holds, for a free-space check before starting. Skipped files still count. */
static long totalSize(final Source src) {
if (!src.isDirectory()) {
return src.length();
}
long total = 0;
for (final Source child : src.children()) {
total += totalSize(child);
}
return total;
}

/** A DocumentFile-backed source; the adapter the app uses over a picked tree. */
static final class DocumentFileSource implements Source {
private final ContentResolver resolver;
private final DocumentFile file;

DocumentFileSource(final ContentResolver resolver, final DocumentFile file) {
this.resolver = resolver;
this.file = file;
}

@Override
public String getName() {
return file.getName();
}

@Override
public boolean isDirectory() {
return file.isDirectory();
}

@Override
public long length() {
return file.length();
}

@Override
public List<Source> children() {
final List<Source> out = new ArrayList<Source>();
for (final DocumentFile child : file.listFiles()) {
out.add(new DocumentFileSource(resolver, child));
}
return out;
}

@Override
public InputStream openStream() throws IOException {
final Uri uri = file.getUri();
final InputStream in = resolver.openInputStream(uri);
if (in == null) {
throw new IOException("no stream for " + uri);
}
return in;
}
}
}
Loading
Loading