diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 96ffad86..cf236c5e 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -58,6 +58,11 @@
android:name="com.httrack.android.FileChooserActivity"
android:label="@string/title_activity_file_chooser" >
+
+ hosts = new LinkedHashSet();
+
+ private static final String STATE_HOSTS = "hosts";
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_browser_login);
+
+ final Bundle extras = getIntent().getExtras();
+ final String url = extras != null ? extras.getString(EXTRA_URL) : null;
+ cookiesFile = extras != null ? File.class.cast(extras.get(EXTRA_COOKIES_FILE)) : null;
+ if (url == null || cookiesFile == null) {
+ Log.w(getClass().getSimpleName(), "no address to log in to");
+ finish();
+ return;
+ }
+
+ web = WebView.class.cast(findViewById(R.id.webLogin));
+ final WebSettings settings = web.getSettings();
+ // A login form usually needs scripts, and often its storage too.
+ settings.setJavaScriptEnabled(true);
+ settings.setDomStorageEnabled(true);
+ // A page here has no business reading our storage, and the mirrors are in it.
+ settings.setAllowFileAccess(false);
+ settings.setAllowContentAccess(false);
+ // A no-op from API 26 on, but it still keeps a password out of the form store below that.
+ settings.setSaveFormData(false);
+ final CookieManager cookies = CookieManager.getInstance();
+ cookies.setAcceptCookie(true);
+ // Single sign-on sets its cookies from a third-party frame, so the login needs them.
+ cookies.setAcceptThirdPartyCookies(web, true);
+ web.setWebViewClient(new LoginClient());
+ getOnBackPressedDispatcher().addCallback(this, backCallback);
+ if (savedInstanceState == null) {
+ web.loadUrl(url);
+ return;
+ }
+ // Recreated: reloading the first address would throw away a login half way through.
+ web.restoreState(savedInstanceState);
+ final List seen = savedInstanceState.getStringArrayList(STATE_HOSTS);
+ if (seen != null) {
+ hosts.addAll(seen);
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(final Bundle out) {
+ super.onSaveInstanceState(out);
+ if (web != null) {
+ web.saveState(out);
+ }
+ out.putStringArrayList(STATE_HOSTS, new ArrayList(hosts));
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (web != null) {
+ // destroy() is only safe off the hierarchy, and leaks the window if it is still attached.
+ final ViewGroup parent = ViewGroup.class.cast(web.getParent());
+ if (parent != null) {
+ parent.removeView(web);
+ }
+ web.destroy();
+ web = null;
+ }
+ super.onDestroy();
+ }
+
+ /** Back walks the login's own history first, so a wrong turn does not leave the screen. */
+ private final OnBackPressedCallback backCallback = new OnBackPressedCallback(true) {
+ @Override
+ public void handleOnBackPressed() {
+ if (web != null && web.canGoBack()) {
+ web.goBack();
+ } else {
+ setResult(RESULT_CANCELED);
+ finish();
+ }
+ }
+ };
+
+ /**
+ * Records where the user went and keeps the window on https. There is deliberately no
+ * onReceivedSslError, because its default refuses a certificate we could not verify.
+ */
+ private final class LoginClient extends WebViewClient {
+ @Override
+ public void onPageStarted(final WebView view, final String url, final Bitmap favicon) {
+ final String host = BrowserLoginPolicy.hostOf(url);
+ if (host != null) {
+ hosts.add(host);
+ }
+ // The user is about to type a password, so name the site they are typing it into.
+ final ActionBar bar = getActionBar();
+ if (bar != null) {
+ bar.setSubtitle(host);
+ }
+ }
+
+ @Override
+ public boolean shouldOverrideUrlLoading(final WebView view, final WebResourceRequest request) {
+ final String url = request.getUrl().toString();
+ if (BrowserLoginPolicy.hostOf(url) != null) {
+ return false;
+ }
+ // No cleartext permission is declared, so http stays blank.
+ Toast.makeText(BrowserLoginActivity.this,
+ getString(R.string.browser_login_https_only), Toast.LENGTH_LONG).show();
+ return true;
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ getMenuInflater().inflate(R.menu.browser_login, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ if (item.getItemId() == R.id.action_login_forget) {
+ forget();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ /** Drop the saved session: the browser's cookies and the file the engine would have read. */
+ private void forget() {
+ if (cookiesFile == null) {
+ return;
+ }
+ CookieManager.getInstance().removeAllCookies(null);
+ CookieManager.getInstance().flush();
+ hosts.clear();
+ if (cookiesFile.exists() && !cookiesFile.delete()) {
+ Log.w(getClass().getSimpleName(), "could not delete " + cookiesFile);
+ }
+ if (web != null) {
+ web.clearCache(true);
+ web.reload();
+ }
+ Toast.makeText(this, getString(R.string.browser_login_forgotten), Toast.LENGTH_SHORT).show();
+ }
+
+ /** "Cancel" */
+ public void onClickLoginCancel(final View view) {
+ setResult(RESULT_CANCELED);
+ finish();
+ }
+
+ /** "Use this login" */
+ public void onClickLoginDone(final View view) {
+ CookieManager.getInstance().flush();
+ final Map byHost = new LinkedHashMap();
+ for (final String host : hosts) {
+ final String header = CookieManager.getInstance()
+ .getCookie(BrowserLoginPolicy.SCHEME + "://" + host + "/");
+ if (header != null && header.length() != 0) {
+ byHost.put(host, header);
+ }
+ }
+ final BrowserLoginCookies.Result result = BrowserLoginCookies.file(byHost);
+ if (result.cookies == 0) {
+ Toast.makeText(this, getString(R.string.browser_login_no_cookies), Toast.LENGTH_LONG).show();
+ return;
+ }
+ if (!writeOwnerOnly(cookiesFile, result.text)) {
+ Toast.makeText(this, getString(R.string.browser_login_write_failed), Toast.LENGTH_LONG)
+ .show();
+ return;
+ }
+ final Intent data = new Intent();
+ data.putExtra(EXTRA_USER_AGENT, web.getSettings().getUserAgentString());
+ data.putExtra(EXTRA_COOKIE_COUNT, result.cookies);
+ data.putExtra(EXTRA_HOST_COUNT, result.hosts);
+ data.putExtra(EXTRA_DROPPED_COUNT, result.dropped);
+ setResult(RESULT_OK, data);
+ finish();
+ }
+
+ /**
+ * Write TEXT to FILE, readable by this app alone.
+ *
+ * The file holds a live session, so it writes a .new beside it and renames, because a write
+ * that failed halfway would leave the previous login truncated and the crawl with nothing.
+ *
+ * @return true when FILE now holds TEXT
+ */
+ static boolean writeOwnerOnly(final File file, final String text) {
+ final File dir = file.getParentFile();
+ if (dir != null && !dir.isDirectory() && !dir.mkdirs()) {
+ return false;
+ }
+ final File staged = new File(file.getPath() + ".new");
+ try (final OutputStream out = new FileOutputStream(staged)) {
+ out.write(text.getBytes(UTF8));
+ } catch (final IOException e) {
+ Log.w(BrowserLoginActivity.class.getSimpleName(), "cannot write " + staged, e);
+ staged.delete();
+ return false;
+ }
+ // Before the rename, so the name the engine reads is never briefly world-readable. The
+ // second argument is ownerOnly, so the pair clears everyone then grants the owner back.
+ staged.setReadable(false, false);
+ staged.setWritable(false, false);
+ staged.setReadable(true, true);
+ staged.setWritable(true, true);
+ if (!staged.renameTo(file)) {
+ Log.w(BrowserLoginActivity.class.getSimpleName(), "cannot rename onto " + file);
+ staged.delete();
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/httrack/android/BrowserLoginCookies.java b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java
new file mode 100644
index 00000000..f52b0edf
--- /dev/null
+++ b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java
@@ -0,0 +1,184 @@
+/*
+HTTrack Android Java Interface.
+
+HTTrack Website Copier, Offline Browser for Windows and Unix
+Copyright (C) Xavier Roche and other contributors
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 3
+of the License, or any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+ */
+
+
+package com.httrack.android;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The Netscape cookies.txt the in-app browser login hands to the engine through -%K.
+ *
+ * WebView gives us a "name=value; name2=value2" header string per host and no attributes, so a
+ * line states the host it came from, every subdomain of it, path "/" and a session expiry.
+ */
+public final class BrowserLoginCookies {
+ private BrowserLoginCookies() {
+ }
+
+ /** Longest domain the loader keeps, because cookie_load_field refuses len >= its buffer. */
+ static final int MAX_DOMAIN = 255;
+
+ /**
+ * Longest name plus value the engine's jar keeps. cookie_add refuses a pair over
+ * COOKIE_MAX_PAIR, which is RFC 6265's limit, so neither field has a cap of its own.
+ * A record can parse and still never reach the jar, so this is the gate that decides.
+ * BrowserLoginEngineLimitsTest checks it against the pinned engine.
+ */
+ static final int MAX_PAIR = 4096;
+
+ /**
+ * Bytes we may write. The engine's jar holds 30000 and loads the mirror's own cookies.txt
+ * first, so this leaves room for both.
+ */
+ static final int MAX_BYTES = 20000;
+
+ /* Both lines are spellings cookie_known_comment accepts, so the second names the engine
+ rather than describing us. An unrecognised comment counts as a line a rewrite would lose,
+ and the engine then refuses to save its jar back, which costs us every cookie the site
+ rotated during the crawl. */
+ static final String HEADER = "# Netscape HTTP Cookie File\n"
+ + "# Written by HTTrack Website Copier\n";
+
+ /** What {@link #file} produced, for the message that tells the user what was saved. */
+ public static final class Result {
+ /** The file text, header included. */
+ public final String text;
+
+ public final int cookies;
+
+ /** Hosts that gave at least one cookie. */
+ public final int hosts;
+
+ /** Cookies left out, because a field was too long or the budget ran out. */
+ public final int dropped;
+
+ Result(final String text, final int cookies, final int hosts, final int dropped) {
+ this.text = text;
+ this.cookies = cookies;
+ this.hosts = hosts;
+ this.dropped = dropped;
+ }
+ }
+
+ /** Bytes, not characters: the engine measures every limit with strlen(). */
+ private static int utf8Length(final String s) {
+ return s.getBytes(StandardCharsets.UTF_8).length;
+ }
+
+ /** Whether FIELD can go in a record. A tab or a newline would split the line. */
+ static boolean isClean(final String field) {
+ return field != null && !OptionValues.hasControlCharacter(field);
+ }
+
+ /**
+ * One record for cookie NAME on HOST. A cookie may hold an empty value, but not an empty name.
+ *
+ * @return the line, newline included, or null when the engine would not keep it
+ */
+ static String line(final String host, final String name, final String value) {
+ if (!isClean(host) || !isClean(name) || !isClean(value)) {
+ return null;
+ }
+ // cookie_load() skips any line starting with '#', and the host leads every record.
+ if (host.length() == 0 || name.length() == 0 || host.charAt(0) == '#') {
+ return null;
+ }
+ if (!OptionValues.fitsBytes(host, MAX_DOMAIN)) {
+ return null;
+ }
+ // Name and value share one budget. An over-long name leaves a negative one, so this
+ // single test refuses the pair whichever side is at fault.
+ if (utf8Length(value) > MAX_PAIR - utf8Length(name)) {
+ return null;
+ }
+ /* No length check on the whole line: the three caps above bound it well under the
+ line_max at which cookie_load drops a line, which BrowserLoginEngineLimitsTest proves. */
+ return host + "\tTRUE\t/\tFALSE\t0\t" + name + "\t" + value + "\n";
+ }
+
+ /**
+ * Split a {@code CookieManager.getCookie()} header into cookies.
+ *
+ * @return one two-element array per cookie, the name then the value, in the header's order
+ */
+ static List split(final String header) {
+ final List cookies = new ArrayList();
+ if (header == null) {
+ return cookies;
+ }
+ for (final String item : header.split(";")) {
+ final String entry = item.trim();
+ final int equals = entry.indexOf('=');
+ // A value may hold '=', so only the first one separates. A nameless entry is not a cookie.
+ if (equals > 0) {
+ cookies.add(new String[] { entry.substring(0, equals).trim(),
+ entry.substring(equals + 1).trim() });
+ }
+ }
+ return cookies;
+ }
+
+ /**
+ * Build the file from what each host's cookie header held.
+ *
+ * @param cookiesByHost
+ * host to the header {@code CookieManager.getCookie()} returned for it, in visit order
+ */
+ public static Result file(final Map cookiesByHost) {
+ final StringBuilder text = new StringBuilder(HEADER);
+ int size = utf8Length(HEADER);
+ int cookies = 0;
+ int hosts = 0;
+ int dropped = 0;
+ for (final Map.Entry entry : cookiesByHost.entrySet()) {
+ /* getCookie lists a name once per scope, and every record flattens onto the host we
+ asked about, so two scopes of one name would repeat in the file. The engine refuses to
+ rewrite a file holding a repeat, so only one may go in. Keep the last, which is both
+ what the engine itself keeps and the newer cookie: RFC 6265 orders equal paths by
+ creation time, earliest first, so a session set during the login sorts last. */
+ final Map byName = new LinkedHashMap();
+ for (final String[] cookie : split(entry.getValue())) {
+ byName.put(cookie[0], cookie[1]);
+ }
+ boolean used = false;
+ for (final Map.Entry cookie : byName.entrySet()) {
+ final String line = line(entry.getKey(), cookie.getKey(), cookie.getValue());
+ if (line == null || size + utf8Length(line) > MAX_BYTES) {
+ dropped++;
+ continue;
+ }
+ text.append(line);
+ size += utf8Length(line);
+ cookies++;
+ used = true;
+ }
+ if (used) {
+ hosts++;
+ }
+ }
+ return new Result(text.toString(), cookies, hosts, dropped);
+ }
+}
diff --git a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java
new file mode 100644
index 00000000..ecd65f88
--- /dev/null
+++ b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java
@@ -0,0 +1,114 @@
+package com.httrack.android;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+
+/**
+ * What the browser login opens, and what it changes about the crawl. A logged-in crawl has to
+ * stay quiet and keep away from the links that end the session, so the hand-off settles those.
+ */
+final class BrowserLoginPolicy {
+ private BrowserLoginPolicy() {
+ }
+
+ /** Connections a logged-in crawl may open: a live session plus many sockets reads as abuse. */
+ static final int MAX_SOCKETS = 2;
+
+ /** Keeps the crawl off the links that end the session. The engine's last match wins. */
+ static final String LOGOUT_FILTER = "-*logout*";
+
+ /** The login window only opens this scheme, because a password does not belong on cleartext. */
+ static final String SCHEME = "https";
+
+ /** A leading "scheme:", which tells an address from a bare host name. */
+ private static final String HAS_SCHEME = "^[a-zA-Z][a-zA-Z0-9+.-]*:.*";
+
+ /**
+ * The address to open for the login, taken from the project's address field. An http address
+ * is raised to https, which leaves the addresses the crawl itself uses untouched.
+ *
+ * @param urlField
+ * the field's text, one or more whitespace-separated addresses
+ * @return the first address we may open, or null when the field holds none
+ */
+ static String startUrl(final String urlField) {
+ // urlTokens also drops what a tampered profile puts in that field, such as an engine option.
+ for (final String token : CommandlineTokens.urlTokens(urlField)) {
+ final String url = secureUrl(token);
+ if (hostOf(url) != null) {
+ return url;
+ }
+ }
+ return null;
+ }
+
+ /** TOKEN as an https address: a bare host gains the scheme, and http is raised to it. */
+ private static String secureUrl(final String token) {
+ if (!token.matches(HAS_SCHEME)) {
+ return SCHEME + "://" + token;
+ }
+ if (token.regionMatches(true, 0, "http://", 0, "http://".length())) {
+ return SCHEME + "://" + token.substring("http://".length());
+ }
+ return token;
+ }
+
+ /**
+ * The host URL names, lowercased the way the engine compares a cookie domain.
+ *
+ * @return the host, or null unless URL is an https address naming one
+ */
+ static String hostOf(final String url) {
+ if (url == null) {
+ return null;
+ }
+ final URI uri;
+ try {
+ uri = new URI(url);
+ } catch (final URISyntaxException e) {
+ return null;
+ }
+ final String host = uri.getHost();
+ // Only https: a WebView given a file: or a content: address would read our own storage.
+ if (host == null || host.length() == 0 || uri.getScheme() == null
+ || !uri.getScheme().equalsIgnoreCase(SCHEME)) {
+ return null;
+ }
+ return host.toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * The connection count to use, which only ever goes down.
+ *
+ * @param current
+ * the Sockets value the project holds, possibly empty or absent
+ */
+ static String sockets(final String current) {
+ final String value = current == null ? "" : current.trim();
+ /* The same ASCII-digit test the option mapper applies, because Integer.parseInt also takes
+ an Arabic-Indic digit that the mapper then refuses, dropping -c and leaving the crawl on
+ the engine's own higher default. */
+ if (OptionValues.isAtMost(value, MAX_SOCKETS)) {
+ final int count = Integer.parseInt(value);
+ if (count > 0) {
+ return String.valueOf(count);
+ }
+ }
+ return String.valueOf(MAX_SOCKETS);
+ }
+
+ /**
+ * The scan rules to use, with the logout filter appended when nothing there mentions logout.
+ *
+ * @param current
+ * the WildCardFilters value the project holds, possibly empty or absent
+ */
+ static String rules(final String current) {
+ final String rules = current == null ? "" : current.trim();
+ if (rules.toLowerCase(Locale.ROOT).indexOf("logout") != -1) {
+ return rules;
+ }
+ return rules.length() == 0 ? LOGOUT_FILTER : rules + " " + LOGOUT_FILTER;
+ }
+}
diff --git a/app/src/main/java/com/httrack/android/CleanupActivity.java b/app/src/main/java/com/httrack/android/CleanupActivity.java
index acc4fe70..aab6e456 100755
--- a/app/src/main/java/com/httrack/android/CleanupActivity.java
+++ b/app/src/main/java/com/httrack/android/CleanupActivity.java
@@ -176,6 +176,15 @@ private static View blocCheckFor(final View clicked) {
return view;
}
+ /** Drop a deleted project's saved browser login, which private storage would otherwise keep. */
+ private void forgetLogin(final String projectName) {
+ for (final File file : StoragePaths.savedLoginFiles(getFilesDir(), projectName)) {
+ if (!file.delete()) {
+ Log.w(getClass().getSimpleName(), "could not delete saved login: " + file);
+ }
+ }
+ }
+
/* Delete recursively a directory, or delete a file. */
public static boolean deleteRecursively(final File file) {
return deleteRecursively(file, MAX_DEPTH);
@@ -241,9 +250,16 @@ public void run() {
MirrorServer.stopAll();
final HashSet deleted = new HashSet();
for (final int position : positions) {
- if (deleteRecursively(new File(projectRootFile, projects[position]))) {
+ final File project = new File(projectRootFile, projects[position]);
+ if (deleteRecursively(project)) {
deleted.add(position);
}
+ /* On whether the mirror is gone, not on whether we removed it, because a directory
+ already deleted from outside the app reports failure and must still drop its
+ session. A partial failure leaves the project, so it keeps its login. */
+ if (!project.exists()) {
+ forgetLogin(projects[position]);
+ }
}
// Root path can be deleted ?
diff --git a/app/src/main/java/com/httrack/android/CleanupTitlePolicy.java b/app/src/main/java/com/httrack/android/CleanupTitlePolicy.java
index 757ded02..fb791a86 100644
--- a/app/src/main/java/com/httrack/android/CleanupTitlePolicy.java
+++ b/app/src/main/java/com/httrack/android/CleanupTitlePolicy.java
@@ -18,4 +18,18 @@ static int titleFor(final boolean selecting) {
return selecting ? R.string.title_activity_cleanup_select
: R.string.title_activity_cleanup;
}
+
+ /**
+ * Must the delete flow be refused? Deleting a mirror a crawl still holds loses the delete and
+ * keeps the session, because the engine recreates what it has open when it winds down. The
+ * "..." picker is not gated, because choosing a project removes nothing. Phrased as the
+ * refusal so its one guard needs no negation, which is the clause a reader inverts by mistake.
+ *
+ * @param crawlLive true while a crawl owns a mirror, in this window or in the job
+ * @param hasProjects true when there is at least one project to show
+ * @return true when the delete flow must not open
+ */
+ static boolean refusesDelete(final boolean crawlLive, final boolean hasProjects) {
+ return !hasProjects || crawlLive;
+ }
}
diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java
index d9b24e7b..8fbc999d 100755
--- a/app/src/main/java/com/httrack/android/HTTrackActivity.java
+++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java
@@ -195,6 +195,7 @@ protected static class VERSION_CODES {
protected static final int ACTIVITY_PROJECT_NAME_CHOOSER = 2;
protected static final int ACTIVITY_CLEANUP = 3;
protected static final int ACTIVITY_IMPORT_TREE = 4;
+ protected static final int ACTIVITY_BROWSER_LOGIN = 5;
// Constant on purpose: a per-process tag travels through saved state and through notification
// extras, so a cold launch would adopt a dead process's tag.
@@ -2177,7 +2178,7 @@ protected void onEnterNewPane() {
// Enable or disable browse & cleanup button depending on existing
// project(s)
View.class.cast(this.findViewById(R.id.buttonClear)).setEnabled(
- hasProjectNames());
+ !CleanupTitlePolicy.refusesDelete(isCrawlLive(), hasProjectNames()));
View.class.cast(this.findViewById(R.id.buttonBrowseAll)).setEnabled(
hasProjectRootIndexFile());
@@ -2885,6 +2886,67 @@ public void onClickMoreProjectName(final View view) {
}
}
+ /**
+ * Where this project's browser login is kept. Private storage, because the file holds a live
+ * session and a mirror folder is readable by every app on the device.
+ *
+ * @return the file, or null until the project has a usable name
+ */
+ private File getLoginCookiesFile() {
+ return StoragePaths.loginCookiesFile(getFilesDir(), mapper.getProjectName());
+ }
+
+ /**
+ * "Log in with a browser"
+ */
+ public void onClickBrowserLogin(final View view) {
+ // The address field is this pane's, and the login opens what it holds right now.
+ savePaneFields();
+ final File cookies = getLoginCookiesFile();
+ if (cookies == null) {
+ Toast.makeText(this, R.string.browser_login_no_project, Toast.LENGTH_LONG).show();
+ return;
+ }
+ final String url = BrowserLoginPolicy.startUrl(getMap(R.id.fieldWebsiteURLs));
+ if (url == null) {
+ Toast.makeText(this, R.string.browser_login_no_address, Toast.LENGTH_LONG).show();
+ return;
+ }
+ final Intent intent = new Intent(this, BrowserLoginActivity.class);
+ intent.putExtra(BrowserLoginActivity.EXTRA_URL, url);
+ intent.putExtra(BrowserLoginActivity.EXTRA_COOKIES_FILE, cookies);
+ startActivityForResult(intent, ACTIVITY_BROWSER_LOGIN);
+ }
+
+ /**
+ * Point the crawl at the login the browser window saved, and tell the user what that changed.
+ */
+ private void applyBrowserLogin(final Intent data) {
+ final File cookies = getLoginCookiesFile();
+ final String agent = data.getStringExtra(BrowserLoginActivity.EXTRA_USER_AGENT);
+ if (cookies == null || agent == null || agent.length() == 0) {
+ return;
+ }
+ setMap(R.id.editCookiesFile, cookies.getAbsolutePath());
+ // The exact string, "wv" included: a clearance bound to the browser's agent breaks on any other.
+ setMap(R.id.editBrowserIdentity, agent);
+ // cookie_load() runs only when the engine accepts cookies, so an unticked box drops the file.
+ setMap(R.id.checkAcceptCookies, "1");
+ setMap(R.id.editNumberOfConnections,
+ BrowserLoginPolicy.sockets(getMap(R.id.editNumberOfConnections)));
+ setMap(R.id.editRules, BrowserLoginPolicy.rules(getMap(R.id.editRules)));
+ final int droppedCookies = data.getIntExtra(BrowserLoginActivity.EXTRA_DROPPED_COUNT, 0);
+ final String saved = getString(R.string.browser_login_saved,
+ data.getIntExtra(BrowserLoginActivity.EXTRA_COOKIE_COUNT, 0),
+ data.getIntExtra(BrowserLoginActivity.EXTRA_HOST_COUNT, 0),
+ BrowserLoginPolicy.MAX_SOCKETS);
+ // A cookie the engine's jar refuses is not saved, so saying only how many were is a lie.
+ final String message = droppedCookies == 0 ? saved
+ : saved + "\n\n" + getString(R.string.browser_login_dropped, droppedCookies);
+ new AlertDialog.Builder(this).setMessage(message)
+ .setPositiveButton(android.R.string.ok, null).show();
+ }
+
/** Restore a previously saved map context. **/
private void loadParcelable(final Parcelable data) {
// Load modified map
@@ -2919,6 +2981,11 @@ protected void onActivityResult(final int requestCode, final int resultCode,
importMirrorsFrom(data.getData());
}
break;
+ case ACTIVITY_BROWSER_LOGIN:
+ if (resultCode == Activity.RESULT_OK && data != null) {
+ applyBrowserLogin(data);
+ }
+ break;
case ACTIVITY_PROJECT_NAME_CHOOSER:
if (resultCode == Activity.RESULT_OK) {
final String projectName = data
@@ -3098,11 +3165,28 @@ protected void fillExtra(final Intent intent) {
intent.putExtra("com.httrack.android.resourceFile", getResourceFile());
}
+ /**
+ * Does a crawl own a mirror right now? The pending term matters as much as the running
+ * one, because a connectivity stop empties the session and re-arms the job, so the copy comes
+ * back and rebuilds whatever was deleted meanwhile. hasLiveRunner already covers the session
+ * slot and the window before a crawl reaches it.
+ */
+ private boolean isCrawlLive() {
+ return hasLiveRunner() || MirrorJobService.isPending(this);
+ }
+
/**
* "Cleanup"
*/
public void onCleanup(final View view) {
final String[] names = getProjectNames();
+ // Also guarded here, not only by the button's enabled state, which performClick ignores.
+ if (CleanupTitlePolicy.refusesDelete(isCrawlLive(), names != null && names.length != 0)) {
+ if (isCrawlLive()) {
+ Toast.makeText(this, R.string.cleanup_while_copying, Toast.LENGTH_LONG).show();
+ }
+ return;
+ }
if (names != null && names.length != 0) {
final Intent intent = new Intent(this, CleanupActivity.class);
fillExtra(intent);
diff --git a/app/src/main/java/com/httrack/android/StoragePaths.java b/app/src/main/java/com/httrack/android/StoragePaths.java
index fdae37fb..c38433fc 100644
--- a/app/src/main/java/com/httrack/android/StoragePaths.java
+++ b/app/src/main/java/com/httrack/android/StoragePaths.java
@@ -2,6 +2,9 @@
import java.io.File;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
/**
* Where mirrors may be written: decides whether a persisted setting is honoured or destroyed.
@@ -231,6 +234,58 @@ static String externalStorageDocId(final File dir, final File shared) {
}
}
+ /**
+ * Where PROJECTNAME's browser login is kept. Private storage, because the file holds a live
+ * session and a mirror folder is readable by every app on the device.
+ *
+ * @param internal
+ * getFilesDir(), the app's own storage
+ * @param projectName
+ * the project, which has to be one valid path segment
+ * @return the file, or null when there is no name we may build a path from
+ */
+ static File loginCookiesFile(final File internal, final String projectName) {
+ if (internal == null || !isValidProjectName(projectName)) {
+ return null;
+ }
+ return new File(new File(internal, "logins"), projectName + ".txt");
+ }
+
+ /**
+ * Every file holding PROJECTNAME's saved login: the one the engine reads, plus whatever its
+ * own save left beside it.
+ *
+ * @param internal
+ * getFilesDir(), the app's own storage
+ * @param projectName
+ * the project, which has to be one valid path segment
+ * @return the files to delete, empty when nothing is saved
+ */
+ static List savedLoginFiles(final File internal, final String projectName) {
+ final File login = loginCookiesFile(internal, projectName);
+ final File dir = login != null ? login.getParentFile() : null;
+ final File[] saved = dir != null ? dir.listFiles() : null;
+ final List files = new ArrayList();
+ if (saved != null) {
+ for (final File file : saved) {
+ if (isLoginSibling(login.getName(), file.getName())) {
+ files.add(file);
+ }
+ }
+ }
+ return files;
+ }
+
+ /**
+ * Whether FILENAME is LOGINNAME, or one of the files the engine's save leaves beside it.
+ * Those are its {@code .bak}, its {@code ..tmp}, and the temporary the {@code .bak} is
+ * staged through, because each one can hold a whole jar. No run deletes another's temporary.
+ */
+ static boolean isLoginSibling(final String ourName, final String candidate) {
+ return candidate != null
+ && candidate.matches(Pattern.quote(ourName) + "(\\.bak)?(\\.[0-9]+\\.tmp)?");
+ }
+
/**
* Whether {@code name} is safe to append to a root as one directory. File does not fold "..", so
* a crafted winprofile.ini name like "../../sdcard/evil" would otherwise steer the engine's -O
diff --git a/app/src/main/jni/httrack b/app/src/main/jni/httrack
index 67d3a174..97b3804a 160000
--- a/app/src/main/jni/httrack
+++ b/app/src/main/jni/httrack
@@ -1 +1 @@
-Subproject commit 67d3a17479e056be97059a7cec0ee6cac426c686
+Subproject commit 97b3804a90d35ab92152b108bab3738fa1f68599
diff --git a/app/src/main/res/layout/activity_browser_login.xml b/app/src/main/res/layout/activity_browser_login.xml
new file mode 100644
index 00000000..3c5c9f08
--- /dev/null
+++ b/app/src/main/res/layout/activity_browser_login.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_proj_setup.xml b/app/src/main/res/layout/activity_proj_setup.xml
index 1e0c03fb..dd26a3c0 100755
--- a/app/src/main/res/layout/activity_proj_setup.xml
+++ b/app/src/main/res/layout/activity_proj_setup.xml
@@ -31,6 +31,15 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 55f3483c..99e7324b 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -269,6 +269,7 @@
Starting the mirror…The mirror cannot make a self-contained page two ways at once. Untick either \"Build a single-file MHTML archive\" (Log, index, cache) or \"Inline assets as data: URIs\" (Build).A mirror is already in progress. Wait for it to finish, or stop it, before you start another one.
+ A copy is running. Wait for it to finish, or stop it, before you delete projects.The copier engine crashed and cannot be used again until HTTrack restarts. The app closes when you leave this screen; open it again to start a new mirror.Copy in progressShows how far a website copy has got, while it runs.
@@ -279,5 +280,18 @@
Reset to default optionsSelect default storageDirectory does not exist
+ Log in with a browser
+ Log in with a browser
+ Log in to the site here, then tap \"Use this login\". Google accounts refuse this window.
+ Use this login
+ Forget the saved login
+ The saved login is deleted.
+ Type a web address first.
+ Give the project a name first.
+ This window opens https addresses only.
+ This site set no cookies, so there is no login to save.
+ The login could not be saved on this device.
+ %1$d cookies were too long for the copier and were left out.
+ The login is saved: %1$d cookies from %2$d hosts.\n\nThe copy sends them with this browser\'s User-Agent. It opens %3$d connections and skips logout links.\n\nA site that uses bot protection can still refuse the copy.
\ No newline at end of file
diff --git a/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java b/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java
new file mode 100644
index 00000000..3decd7c5
--- /dev/null
+++ b/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java
@@ -0,0 +1,217 @@
+package com.httrack.android;
+
+import static org.junit.Assert.assertArrayEquals;
+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 java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Test;
+
+/**
+ * The cookies.txt handed to -%K, against what cookie_load() in the pinned engine reads: fields
+ * 0, 2, 5 and 6 of a tab-separated line, any line starting with '#' skipped.
+ */
+public class BrowserLoginCookiesTest {
+ private static Map one(final String host, final String header) {
+ final Map byHost = new LinkedHashMap();
+ byHost.put(host, header);
+ return byHost;
+ }
+
+ /** Records only, header lines dropped. */
+ private static List records(final String text) {
+ final java.util.List lines = new java.util.ArrayList();
+ for (final String line : text.split("\n")) {
+ if (line.length() != 0 && line.charAt(0) != '#') {
+ lines.add(line);
+ }
+ }
+ return lines;
+ }
+
+ @Test
+ public void writesTheSevenFieldsTheEngineReads() {
+ final BrowserLoginCookies.Result result =
+ BrowserLoginCookies.file(one("www.example.com", "sess=abc123; theme=dark"));
+ final List lines = records(result.text);
+ assertEquals(2, lines.size());
+ final String[] fields = lines.get(0).split("\t", -1);
+ assertEquals(7, fields.length);
+ assertEquals("www.example.com", fields[0]);
+ assertEquals("TRUE", fields[1]);
+ assertEquals("/", fields[2]);
+ assertEquals("FALSE", fields[3]);
+ assertEquals("0", fields[4]);
+ assertEquals("sess", fields[5]);
+ assertEquals("abc123", fields[6]);
+ assertEquals(2, result.cookies);
+ assertEquals(1, result.hosts);
+ assertEquals(0, result.dropped);
+ }
+
+ /** cookie_load() skips every line starting with '#', so no record may begin with one. */
+ @Test
+ public void noRecordStartsWithAComment() {
+ // A '#' is legal inside a name, and the host leads the record, so the line still parses.
+ final BrowserLoginCookies.Result result =
+ BrowserLoginCookies.file(one("x.example", "#odd=abc; ok=1"));
+ assertEquals(2, result.cookies);
+ for (final String line : records(result.text)) {
+ assertFalse(line, line.startsWith("#"));
+ }
+ // A host is the one field that could start the line with one, so it is refused there.
+ assertNull(BrowserLoginCookies.line("#x.example", "n", "v"));
+ }
+
+ @Test
+ public void keepsTheWholeValuePastItsFirstEquals() {
+ final String value = BrowserLoginCookies.file(one("h.example", "jwt=aa=bb==")).text;
+ assertTrue(value, value.contains("\tjwt\taa=bb==\n"));
+ }
+
+ @Test
+ public void keepsAnEmptyValueButDropsAnEmptyName() {
+ assertNotNull(BrowserLoginCookies.line("h.example", "sess", ""));
+ assertNull(BrowserLoginCookies.line("h.example", "", "v"));
+ // "=v" has no name, so split() never offers it as a cookie.
+ assertTrue(BrowserLoginCookies.split("=v").isEmpty());
+ }
+
+ @Test
+ public void splitReadsNameThenValueInOrder() {
+ final List cookies = BrowserLoginCookies.split("a=1; b=2");
+ assertEquals(2, cookies.size());
+ assertArrayEquals(new String[] { "a", "1" }, cookies.get(0));
+ assertArrayEquals(new String[] { "b", "2" }, cookies.get(1));
+ }
+
+ /** A tab would add a field and a newline a record, so neither may reach the file. */
+ @Test
+ public void refusesAFieldThatWouldForgeARecord() {
+ assertNull(BrowserLoginCookies.line("h.example", "a\tb", "v"));
+ assertNull(BrowserLoginCookies.line("h.example", "n", "v\nevil.example\tTRUE\t/"));
+ assertNull(BrowserLoginCookies.line("h.example", "n", "v\rx"));
+ assertNull(BrowserLoginCookies.line("evil\t.example", "n", "v"));
+ assertNull(BrowserLoginCookies.line("h.example", "n", "v\u0000x"));
+ assertNull(BrowserLoginCookies.line("h.example", "n", "v\u007fx"));
+ }
+
+ /** The jar spends one budget across the pair, so neither field has a limit of its own. */
+ @Test
+ public void refusesAPairTheJarWouldNotHold() {
+ final String host = "h.example";
+ final int pair = BrowserLoginCookies.MAX_PAIR;
+ // Exactly the budget fits, whichever side of the pair carries it.
+ assertNotNull(BrowserLoginCookies.line(host, "n", repeat("v", pair - 1)));
+ assertNotNull(BrowserLoginCookies.line(host, repeat("n", pair), ""));
+ // Both sides loaded and summing to the budget, which no other case covers.
+ assertNotNull(BrowserLoginCookies.line(host, repeat("n", pair / 2), repeat("v", pair / 2)));
+ // One byte more does not, and a long name now costs the value its room.
+ assertNull(BrowserLoginCookies.line(host, "n", repeat("v", pair)));
+ assertNull(BrowserLoginCookies.line(host, repeat("n", pair + 1), ""));
+ assertNull(BrowserLoginCookies.line(host, repeat("n", pair / 2), repeat("v", pair / 2 + 1)));
+ assertNull(BrowserLoginCookies.line(repeat("d", BrowserLoginCookies.MAX_DOMAIN + 1), "n", "v"));
+ }
+
+ /** strlen() counts bytes, so a two-byte character must count twice against every limit. */
+ @Test
+ public void measuresLimitsInBytesNotCharacters() {
+ /* Half as many characters as the limit, so exactly the limit in bytes. Java's count says
+ it is half spent and the engine's says it is all spent, which is the divergence. */
+ final String twoByte = repeat("é", BrowserLoginCookies.MAX_PAIR / 2);
+ assertEquals(BrowserLoginCookies.MAX_PAIR / 2, twoByte.length());
+ assertNull(BrowserLoginCookies.line("h.example", twoByte, "v"));
+ // One character shorter leaves one byte, which the value then fills exactly.
+ assertNotNull(BrowserLoginCookies.line("h.example",
+ repeat("é", BrowserLoginCookies.MAX_PAIR / 2 - 1), "v"));
+ }
+
+ @Test
+ public void stopsAtTheByteBudgetAndCountsTheRest() {
+ final StringBuilder header = new StringBuilder();
+ final int cookies = 60;
+ for (int i = 0; i < cookies; i++) {
+ header.append("n").append(i).append("=").append(repeat("v", 500)).append("; ");
+ }
+ final BrowserLoginCookies.Result result =
+ BrowserLoginCookies.file(one("h.example", header.toString()));
+ assertTrue(result.text.getBytes(java.nio.charset.Charset.forName("UTF-8")).length
+ <= BrowserLoginCookies.MAX_BYTES);
+ assertTrue(result.dropped > 0);
+ assertEquals(cookies, result.cookies + result.dropped);
+ }
+
+ /**
+ * A cookie the jar will not keep must not be reported as saved. cookie_add refuses an
+ * over-budget pair even though the line parses, so counting it would tell the user a session
+ * was saved while the crawl runs logged out.
+ */
+ @Test
+ public void aCookieTheJarWouldRefuseCountsAsDropped() {
+ final BrowserLoginCookies.Result result = BrowserLoginCookies.file(one("h.example",
+ "small=ok; big=" + repeat("v", BrowserLoginCookies.MAX_PAIR)));
+ assertEquals(1, result.cookies);
+ assertEquals(1, result.dropped);
+ assertFalse(result.text, result.text.contains("big"));
+ }
+
+ /** One line per name, holding the last value, which is what the engine would have kept. */
+ @Test
+ public void aRepeatedNameKeepsTheLastValue() {
+ final BrowserLoginCookies.Result result =
+ BrowserLoginCookies.file(one("www.example.com", "sid=older; sid=newer; other=1"));
+ assertEquals(2, result.cookies);
+ // Superseded rather than lost, so it is not reported to the user as left out.
+ assertEquals(0, result.dropped);
+ assertTrue(result.text, result.text.contains("\tsid\tnewer\n"));
+ assertFalse(result.text, result.text.contains("older"));
+ }
+
+ /** The same name on two hosts is two cookies, so the key has to carry the host. */
+ @Test
+ public void aNameOnTwoHostsSurvivesOnBoth() {
+ final Map byHost = new LinkedHashMap();
+ byHost.put("a.example", "sid=one");
+ byHost.put("b.example", "sid=two");
+ final BrowserLoginCookies.Result result = BrowserLoginCookies.file(byHost);
+ assertEquals(2, result.cookies);
+ assertEquals(2, result.hosts);
+ assertTrue(result.text, result.text.contains("a.example\tTRUE\t/\tFALSE\t0\tsid\tone\n"));
+ assertTrue(result.text, result.text.contains("b.example\tTRUE\t/\tFALSE\t0\tsid\ttwo\n"));
+ }
+
+ @Test
+ public void countsOnlyHostsThatGaveACookie() {
+ final Map byHost = new LinkedHashMap();
+ byHost.put("a.example", "one=1");
+ byHost.put("b.example", "");
+ byHost.put("c.example", "two=2");
+ final BrowserLoginCookies.Result result = BrowserLoginCookies.file(byHost);
+ assertEquals(2, result.hosts);
+ assertEquals(2, result.cookies);
+ // Visit order is kept, so the login's own host leads the file.
+ assertTrue(result.text.indexOf("a.example") < result.text.indexOf("c.example"));
+ }
+
+ @Test
+ public void anEmptyLoginWritesNoRecord() {
+ final BrowserLoginCookies.Result result =
+ BrowserLoginCookies.file(new LinkedHashMap());
+ assertEquals(0, result.cookies);
+ assertTrue(records(result.text).isEmpty());
+ }
+
+ private static String repeat(final String unit, final int times) {
+ final StringBuilder text = new StringBuilder();
+ for (int i = 0; i < times; i++) {
+ text.append(unit);
+ }
+ return text.toString();
+ }
+}
diff --git a/app/src/test/java/com/httrack/android/BrowserLoginEngineLimitsTest.java b/app/src/test/java/com/httrack/android/BrowserLoginEngineLimitsTest.java
new file mode 100644
index 00000000..295e428a
--- /dev/null
+++ b/app/src/test/java/com/httrack/android/BrowserLoginEngineLimitsTest.java
@@ -0,0 +1,119 @@
+package com.httrack.android;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.junit.Test;
+
+/**
+ * The cookie limits against the pinned engine, which owns them. A record passes two gates, and
+ * only the tighter one decides whether the cookie is kept: cookie_load reads each field into a
+ * buffer, and cookie_add then refuses a pair the jar cannot hold.
+ */
+public class BrowserLoginEngineLimitsTest {
+ private static String source(final String name) throws IOException {
+ return TestSources.read(TestSources.engineFile("src/" + name));
+ }
+
+ /**
+ * Just the cookies.txt branch of cookie_load. The Windows branch above it merges copied IE
+ * cookies through buffers of its own, and one of them is spelled the same.
+ */
+ private static String parseBlock(final String htsbauth) {
+ return TestSources.between(htsbauth, "// then cookies.txt", "-- basic auth --");
+ }
+
+ /** The one number a regex matched, so a changed engine reads as a failure, never as a pass. */
+ private static int only(final String text, final String regex) {
+ final Matcher match = Pattern.compile(regex).matcher(text);
+ if (!match.find()) {
+ throw new IllegalStateException("no " + regex);
+ }
+ final int value = Integer.parseInt(match.group(1));
+ if (match.find()) {
+ throw new IllegalStateException("more than one " + regex);
+ }
+ return value;
+ }
+
+ /** A buffer of SIZE bytes holds SIZE-1, because cookie_load_field refuses len >= dst_size. */
+ @Test
+ public void theDomainLimitIsTheLoadersBuffer() throws IOException {
+ assertEquals(only(parseBlock(source("htsbauth.c")), "char domain\\[(\\d+)\\]") - 1,
+ BrowserLoginCookies.MAX_DOMAIN);
+ }
+
+ /**
+ * cookie_add caps name and value together against COOKIE_MAX_PAIR, so neither field has a
+ * limit of its own and the writer must spend one budget across both.
+ */
+ @Test
+ public void thePairLimitIsTheEnginesOwnConstant() throws IOException {
+ assertEquals(only(source("htsbauth.h"), "COOKIE_MAX_PAIR (\\d+)"),
+ BrowserLoginCookies.MAX_PAIR);
+ // The guard really is joint: the value is measured against what the name leaves.
+ final String guard = source("htsbauth.c");
+ assertTrue("cookie_add no longer caps the pair jointly",
+ guard.contains("COOKIE_MAX_PAIR - strlen(cook_name)"));
+ }
+
+ /**
+ * The loader reads a field before the jar caps it, so its buffers have to hold everything the
+ * pair cap allows. A buffer shrunk below that would make cookie_load skip whole lines we
+ * consider legal, which is a silent loss rather than a refusal.
+ */
+ @Test
+ public void theLoadersBuffersHoldWhatThePairCapAllows() throws IOException {
+ final String block = parseBlock(source("htsbauth.c"));
+ // The name buffer is sized from the cap itself, so it cannot fall behind it.
+ assertTrue("cook_name is no longer sized from the pair cap",
+ block.contains("cook_name[COOKIE_MAX_PAIR + 1]"));
+ assertTrue("the cook_value buffer is below the pair cap",
+ only(block, "cook_value\\[(\\d+)\\]") - 1 >= BrowserLoginCookies.MAX_PAIR);
+ }
+
+ /**
+ * cookie_load drops a whole line at line_max. The caps above have to keep every line we can
+ * write under that by themselves, which is why line() carries no length check of its own.
+ */
+ @Test
+ public void theCapsKeepEveryLineUnderTheEnginesLineMax() throws IOException {
+ final int lineMax = only(parseBlock(source("htsbauth.c")), "line_max = (\\d+)");
+ final int fixedFields = "\tTRUE\t/\tFALSE\t0\t".length() + "\t".length();
+ final int longest = BrowserLoginCookies.MAX_DOMAIN + BrowserLoginCookies.MAX_PAIR
+ + fixedFields;
+ assertTrue(longest + " vs " + lineMax, longest < lineMax);
+ }
+
+ /**
+ * Every header line has to be one cookie_known_comment accepts. The engine counts a comment
+ * it does not know as a line a rewrite would lose, and then refuses to save its jar back over
+ * our file, so the crawl silently keeps the stale session instead of the rotated one.
+ */
+ @Test
+ public void everyHeaderLineIsACommentTheEngineKnows() throws IOException {
+ // The array initializer alone, so a comment elsewhere in the function cannot pose as it.
+ final String known = TestSources.between(source("htsbauth.c"), "known[] = {", "};");
+ final java.util.Map host = new java.util.LinkedHashMap();
+ host.put("h.example", "a=1");
+ // What file() emits, not the constant, so an extra comment from anywhere is caught.
+ for (final String line : BrowserLoginCookies.file(host).text.split("\n")) {
+ if (line.isEmpty() || line.charAt(0) != '#') {
+ break;
+ }
+ assertTrue(line + " is not in cookie_known_comment", known.contains('"' + line + '"'));
+ }
+ }
+
+ /** Our file has to leave the mirror's own cookies.txt room in the same jar. */
+ @Test
+ public void theByteBudgetFitsInsideTheJar() throws IOException {
+ final int jar = only(source("htscore.c"), "cookie.max_len = (\\d+)");
+ assertTrue(jar + " vs " + BrowserLoginCookies.MAX_BYTES,
+ BrowserLoginCookies.MAX_BYTES < jar);
+ }
+}
diff --git a/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java
new file mode 100644
index 00000000..46f8d847
--- /dev/null
+++ b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java
@@ -0,0 +1,110 @@
+package com.httrack.android;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/** Which address the login window opens, and what the hand-off changes about the crawl. */
+public class BrowserLoginPolicyTest {
+ @Test
+ public void aBareHostGainsTheSecureScheme() {
+ assertEquals("https://www.example.com", BrowserLoginPolicy.startUrl("www.example.com"));
+ }
+
+ /** The app declares no cleartext permission, so a WebView given http would stay blank. */
+ @Test
+ public void httpIsRaisedToHttps() {
+ assertEquals("https://example.com/in", BrowserLoginPolicy.startUrl("http://example.com/in"));
+ assertEquals("https://example.com/in", BrowserLoginPolicy.startUrl("HTTP://example.com/in"));
+ }
+
+ @Test
+ public void anHttpsAddressIsLeftAlone() {
+ assertEquals("https://example.com/a?b=c",
+ BrowserLoginPolicy.startUrl("https://example.com/a?b=c"));
+ }
+
+ @Test
+ public void theFirstAddressOfTheFieldWins() {
+ assertEquals("https://first.example",
+ BrowserLoginPolicy.startUrl("first.example\nsecond.example"));
+ assertEquals("https://first.example",
+ BrowserLoginPolicy.startUrl(" first.example second.example "));
+ }
+
+ /** A tampered profile puts engine options in that field, and they are not addresses. */
+ @Test
+ public void skipsWhatIsNotAnAddress() {
+ assertEquals("https://real.example", BrowserLoginPolicy.startUrl("-O /evil real.example"));
+ assertNull(BrowserLoginPolicy.startUrl("ftp://files.example"));
+ assertNull(BrowserLoginPolicy.startUrl("file:///etc/passwd"));
+ assertNull(BrowserLoginPolicy.startUrl("javascript:alert(1)"));
+ assertNull(BrowserLoginPolicy.startUrl("content://media/external"));
+ }
+
+ @Test
+ public void anEmptyFieldOpensNothing() {
+ assertNull(BrowserLoginPolicy.startUrl(null));
+ assertNull(BrowserLoginPolicy.startUrl(""));
+ assertNull(BrowserLoginPolicy.startUrl(" "));
+ }
+
+ /** The engine compares a cookie domain without case, and our file states one host per line. */
+ @Test
+ public void hostIsLowercasedAndStrippedOfTheRest() {
+ assertEquals("www.example.com", BrowserLoginPolicy.hostOf("https://WWW.Example.COM/x"));
+ assertEquals("example.com", BrowserLoginPolicy.hostOf("https://example.com:8443/x?y=z"));
+ assertEquals("example.com", BrowserLoginPolicy.hostOf("https://user@example.com/x"));
+ }
+
+ @Test
+ public void onlyHttpsNamesAHost() {
+ assertNull(BrowserLoginPolicy.hostOf("http://example.com/"));
+ assertNull(BrowserLoginPolicy.hostOf("file:///data/data/com.httrack.android/"));
+ assertNull(BrowserLoginPolicy.hostOf("intent://scan#Intent;scheme=zxing;end"));
+ assertNull(BrowserLoginPolicy.hostOf("https:///nohost"));
+ assertNull(BrowserLoginPolicy.hostOf("not a url at all"));
+ assertNull(BrowserLoginPolicy.hostOf(null));
+ }
+
+ @Test
+ public void connectionsOnlyEverGoDown() {
+ assertEquals("2", BrowserLoginPolicy.sockets(""));
+ assertEquals("2", BrowserLoginPolicy.sockets(null));
+ assertEquals("2", BrowserLoginPolicy.sockets("8"));
+ assertEquals("2", BrowserLoginPolicy.sockets("2"));
+ assertEquals("1", BrowserLoginPolicy.sockets("1"));
+ assertEquals("1", BrowserLoginPolicy.sockets(" 1 "));
+ // Neither a zero nor a word is a count the engine would honour.
+ assertEquals("2", BrowserLoginPolicy.sockets("0"));
+ assertEquals("2", BrowserLoginPolicy.sockets("00"));
+ assertEquals("2", BrowserLoginPolicy.sockets("lots"));
+ // A value too big for an int must not throw on the way to the ceiling.
+ assertEquals("2", BrowserLoginPolicy.sockets("999999999999999999999"));
+ /* setText() can put an Arabic-Indic digit in the field. Integer.parseInt reads it as two,
+ but the option mapper only emits -c for ASCII digits, so echoing it back would drop -c
+ and leave the crawl on the engine's own higher default. */
+ assertEquals("2", BrowserLoginPolicy.sockets("\u0662"));
+ // The value a project holds is handed on in the form the mapper accepts.
+ assertEquals("1", BrowserLoginPolicy.sockets("01"));
+ }
+
+ @Test
+ public void theLogoutFilterIsAppendedLastSoItWins() {
+ assertEquals("-*logout*", BrowserLoginPolicy.rules(""));
+ assertEquals("-*logout*", BrowserLoginPolicy.rules(null));
+ final String kept = "+*.png -ad.example/*";
+ assertEquals(kept + " -*logout*", BrowserLoginPolicy.rules(kept));
+ assertTrue(BrowserLoginPolicy.rules(kept).endsWith(BrowserLoginPolicy.LOGOUT_FILTER));
+ }
+
+ @Test
+ public void anExistingLogoutRuleIsLeftAsTheUserWroteIt() {
+ assertEquals("-*/logout", BrowserLoginPolicy.rules("-*/logout"));
+ assertEquals("+*/Logout", BrowserLoginPolicy.rules("+*/Logout"));
+ // Only the word itself counts, so a rule about another name still gets the filter.
+ assertEquals("-*signout* -*logout*", BrowserLoginPolicy.rules("-*signout*"));
+ }
+}
diff --git a/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java
new file mode 100644
index 00000000..76726be9
--- /dev/null
+++ b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java
@@ -0,0 +1,159 @@
+package com.httrack.android;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.junit.Test;
+
+/**
+ * Guards on the browser login's wiring. These read the sources and resources, so they are
+ * lint-shaped: they see a name that went missing, never a wrong decision. The decisions live in
+ * BrowserLoginPolicy and BrowserLoginCookies, which are tested by running them.
+ */
+public class BrowserLoginWiringTest {
+ /** An undeclared activity is an ActivityNotFoundException the moment the button is tapped. */
+ @Test
+ public void theManifestDeclaresTheLoginActivity() throws IOException {
+ final String manifest = TestSources.read(TestSources.mainFile("AndroidManifest.xml"));
+ assertTrue(manifest.contains("com.httrack.android.BrowserLoginActivity"));
+ assertTrue(manifest.contains("@string/title_activity_browser_login"));
+ }
+
+ /** An android:onClick with no matching public method crashes when the view is tapped. */
+ @Test
+ public void everyOnClickOfTheLoginScreensResolves() throws IOException {
+ assertHandled("activity_browser_login", "BrowserLoginActivity");
+ assertHandled("activity_proj_setup", "HTTrackActivity");
+ }
+
+ private static void assertHandled(final String layout, final String owner) throws IOException {
+ final String source = TestSources.javaSource(owner);
+ int seen = 0;
+ // Every qualified variant too: each one is a layout the activity may inflate.
+ for (final java.io.File file : TestSources.layouts(layout)) {
+ final Matcher click = Pattern.compile("android:onClick=\"([^\"]+)\"")
+ .matcher(TestSources.read(file));
+ while (click.find()) {
+ final String method = click.group(1);
+ assertTrue(owner + " has no " + method,
+ source.contains("public void " + method + "(final View "));
+ seen++;
+ }
+ }
+ assertTrue(layout + " declares no onClick", seen != 0);
+ }
+
+ /** lint does not abort the build here, so a missing string is only found by tapping. */
+ @Test
+ public void everyStringTheLoginUsesIsDeclared() throws IOException {
+ final String strings = TestSources.read(TestSources.resFile("values/strings.xml"));
+ final List names = new ArrayList();
+ for (final String file : new String[] { "layout/activity_browser_login.xml",
+ "menu/browser_login.xml" }) {
+ collect(TestSources.read(TestSources.resFile(file)), "@string/([A-Za-z0-9_]+)", names);
+ }
+ for (final String owner : new String[] { "BrowserLoginActivity", "HTTrackActivity" }) {
+ collect(TestSources.javaSource(owner), "R\\.string\\.(browser_login[A-Za-z0-9_]*)", names);
+ }
+ // The two resources name ten today, so a collection that silently broke reads as a failure.
+ assertTrue("only " + names.size() + " strings found", names.size() >= 10);
+ for (final String name : names) {
+ assertTrue("no ",
+ strings.contains(""));
+ }
+ }
+
+ private static void collect(final String text, final String regex, final List into) {
+ final Matcher match = Pattern.compile(regex).matcher(text);
+ while (match.find()) {
+ into.add(match.group(1));
+ }
+ }
+
+ /**
+ * The login window's hardening, each a decision a later edit could undo in silence.
+ */
+ @Test
+ public void theLoginWindowStaysLockedDown() throws IOException {
+ final String source = TestSources
+ .withoutCommentsAndStrings(TestSources.javaSource("BrowserLoginActivity"));
+ // A bridge object would hand the logged-in page our own code.
+ assertFalse(source.contains("addJavascriptInterface"));
+ // Overriding this is how a WebView ends up accepting any certificate for a password form.
+ assertFalse(source.contains("onReceivedSslError"));
+ for (final String locked : new String[] { "setAllowFileAccess(false)",
+ "setAllowContentAccess(false)" }) {
+ assertTrue(locked, source.contains(locked));
+ }
+ }
+
+ /** The file holds a live session, so it must not land where other apps read the mirrors. */
+ @Test
+ public void theCookieFileGoesToPrivateStorage() throws IOException {
+ final String source = TestSources
+ .withoutCommentsAndStrings(TestSources.javaSource("HTTrackActivity"));
+ final String body = TestSources.balancedBlock(source,
+ TestSources.indexOf(source, "private File getLoginCookiesFile()"));
+ assertTrue(body.contains("getFilesDir()"));
+ assertFalse(body.contains("getProjectRootFile()"));
+ assertFalse(body.contains("getTargetFile()"));
+ }
+
+ /** The delete flow must wait for a crawl, or the engine recreates the login it just removed. */
+ @Test
+ public void cleanupWaitsForALiveCrawl() throws IOException {
+ final String source = TestSources
+ .withoutCommentsAndStrings(TestSources.javaSource("HTTrackActivity"));
+ final String body = TestSources.balancedBlock(source,
+ TestSources.indexOf(source, "public void onCleanup("));
+ /* The whole call, because argument order is what a grep otherwise cannot see, and these
+ two booleans transpose silently. Phrased as a refusal, so there is no "!" to invert. */
+ assertTrue(body, body.contains(
+ "if (CleanupTitlePolicy.refusesDelete(isCrawlLive(), names != null"));
+ // The button's enabled state has to ask the same question, or it goes stale on its own.
+ assertTrue(source, source.contains(
+ "!CleanupTitlePolicy.refusesDelete(isCrawlLive(), hasProjectNames())"));
+ // The button's enabled state alone is not a guard, because performClick ignores it.
+ final String live = TestSources.balancedBlock(source,
+ TestSources.indexOf(source, "private boolean isCrawlLive()"));
+ assertTrue(live, live.contains("hasLiveRunner()"));
+ // A connectivity stop empties the session and re-arms the job, so pending counts as live.
+ assertTrue(live, live.contains("MirrorJobService.isPending(this)"));
+ }
+
+ /** Deleting a project has to take its saved login with it, or a live session is orphaned. */
+ @Test
+ public void deletingAProjectForgetsItsLogin() throws IOException {
+ final String source = TestSources
+ .withoutCommentsAndStrings(TestSources.javaSource("CleanupActivity"));
+ final String body = TestSources.balancedBlock(source,
+ TestSources.indexOf(source, "protected void deleteProjects()"));
+ assertTrue(body, body.contains("forgetLogin(projects[position])"));
+ // Gated on the mirror being gone, so a folder deleted outside the app still drops it.
+ assertTrue(body, body.contains("!project.exists()"));
+ // The path comes from the one helper both callers use, never a second copy.
+ final String forget = TestSources.balancedBlock(source,
+ TestSources.indexOf(source, "private void forgetLogin("));
+ assertTrue(forget, forget.contains("StoragePaths.savedLoginFiles("));
+ }
+
+ /** -%K is read only when the engine accepts cookies, so the hand-off has to tick the box. */
+ @Test
+ public void theHandOverTurnsCookiesOn() throws IOException {
+ // The raw source, because the value is the point here and blanking literals would hide it.
+ final String source = TestSources.javaSource("HTTrackActivity");
+ final String blanked = TestSources.withoutCommentsAndStrings(source);
+ final int at = TestSources.indexOf(blanked, "private void applyBrowserLogin(");
+ final String body = source.substring(at,
+ at + TestSources.balancedBlock(blanked, at).length());
+ assertTrue(body, body.contains("setMap(R.id.checkAcceptCookies, \"1\")"));
+ assertTrue(body, body.contains("setMap(R.id.editCookiesFile,"));
+ assertTrue(body, body.contains("setMap(R.id.editBrowserIdentity,"));
+ }
+}
diff --git a/app/src/test/java/com/httrack/android/CleanupTitlePolicyTest.java b/app/src/test/java/com/httrack/android/CleanupTitlePolicyTest.java
index 679c2de8..5677be78 100644
--- a/app/src/test/java/com/httrack/android/CleanupTitlePolicyTest.java
+++ b/app/src/test/java/com/httrack/android/CleanupTitlePolicyTest.java
@@ -1,11 +1,29 @@
package com.httrack.android;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
import org.junit.Test;
/** Truth table for the picker's title decision. */
public class CleanupTitlePolicyTest {
+ /**
+ * The crawl recreates what it still holds, so a delete during one keeps the session and
+ * loses the delete. Choosing a project removes nothing, so that flow stays open.
+ */
+ @Test
+ public void theDeleteFlowWaitsForTheCrawl() {
+ assertTrue("deleting during a crawl", CleanupTitlePolicy.refusesDelete(true, true));
+ assertFalse("deleting with no crawl", CleanupTitlePolicy.refusesDelete(false, true));
+ }
+
+ @Test
+ public void theDeleteFlowNeedsAProject() {
+ assertTrue(CleanupTitlePolicy.refusesDelete(false, false));
+ assertTrue(CleanupTitlePolicy.refusesDelete(true, false));
+ }
+
/** Each answer is written out rather than computed, so the table cannot agree with a change
* to the expression it checks. */
@Test
diff --git a/app/src/test/java/com/httrack/android/StoragePathsTest.java b/app/src/test/java/com/httrack/android/StoragePathsTest.java
index e1457093..70e68617 100644
--- a/app/src/test/java/com/httrack/android/StoragePathsTest.java
+++ b/app/src/test/java/com/httrack/android/StoragePathsTest.java
@@ -28,10 +28,82 @@ public void setUp() throws Exception {
internal = tmp.newFolder("int", "data", "com.httrack.android", "files");
}
- /**
- * The resource bundle is extracted entry by entry, and a ".." in a name would land outside the
- * cache. Canonical paths are what the guard compares, so the escape must be built that way.
- */
+ /** The engine keeps a .bak and stages a .tmp, so a delete that takes only the name leaks. */
+ @Test
+ public void aDeletedLoginTakesWhatTheEngineLeftBesideIt() {
+ assertTrue(StoragePaths.isLoginSibling("My Site.txt", "My Site.txt"));
+ assertTrue(StoragePaths.isLoginSibling("My Site.txt", "My Site.txt.bak"));
+ assertTrue(StoragePaths.isLoginSibling("My Site.txt", "My Site.txt.4321.tmp"));
+ /* The .bak is staged through a temporary of its own, and that file holds a whole jar, so
+ the suffixes stack. The engine's own self-test asserts this name. */
+ assertTrue(StoragePaths.isLoginSibling("My Site.txt", "My Site.txt.bak.4321.tmp"));
+ }
+
+ @Test
+ public void aDeletedLoginLeavesAnotherProjectAlone() {
+ assertFalse(StoragePaths.isLoginSibling("A.txt", "B.txt"));
+ assertFalse(StoragePaths.isLoginSibling("A.txt", "A.txt.bak.txt"));
+ // A project may legitimately be called "A.txt", whose own login file is "A.txt.txt".
+ assertFalse(StoragePaths.isLoginSibling("A.txt", "A.txt.txt"));
+ assertFalse(StoragePaths.isLoginSibling("A.txt", "A.txt.tmp"));
+ assertFalse(StoragePaths.isLoginSibling("A.txt", "A.txt.bak2"));
+ assertFalse(StoragePaths.isLoginSibling("A.txt", "A.txt.bak.tmp"));
+ assertFalse(StoragePaths.isLoginSibling("A.txt", "A.txt.4321.bak"));
+ assertFalse(StoragePaths.isLoginSibling("A.txt", null));
+ /* isValidProjectName allows a regex metacharacter, so the name is quoted before it becomes
+ a pattern. Unquoted, "A+" would match another project's "AA.txt" and delete its session,
+ and "a(" would throw mid-delete. */
+ assertFalse(StoragePaths.isLoginSibling("A+.txt", "AA.txt"));
+ assertTrue(StoragePaths.isLoginSibling("a(.txt", "a(.txt"));
+ assertTrue(StoragePaths.isLoginSibling("a(.txt", "a(.txt.bak"));
+ }
+
+ /** Over real files, because the matching decides what gets deleted off a user's device. */
+ @Test
+ public void savedLoginFilesTakesOurOwnAndLeavesTheNeighbour() throws IOException {
+ final File internal = tmp.newFolder("files");
+ final File logins = new File(internal, "logins");
+ assertTrue(logins.mkdirs());
+ for (final String name : new String[] { "P.txt", "P.txt.bak", "P.txt.4321.tmp",
+ "P.txt.bak.4321.tmp", "Q.txt", "Q.txt.bak", "notes.txt" }) {
+ assertTrue(new File(logins, name).createNewFile());
+ }
+ final java.util.List ours = StoragePaths.savedLoginFiles(internal, "P");
+ final java.util.Set names = new java.util.TreeSet();
+ for (final File file : ours) {
+ names.add(file.getName());
+ }
+ assertEquals(new java.util.TreeSet(java.util.Arrays.asList("P.txt", "P.txt.bak",
+ "P.txt.4321.tmp", "P.txt.bak.4321.tmp")), names);
+ }
+
+ @Test
+ public void savedLoginFilesIsEmptyBeforeAnyLoginIsSaved() throws IOException {
+ final File internal = tmp.newFolder("files-empty");
+ assertTrue(StoragePaths.savedLoginFiles(internal, "P").isEmpty());
+ assertTrue(StoragePaths.savedLoginFiles(internal, "..").isEmpty());
+ }
+
+ /** A deleted project must not leave a live session behind, so both callers share this path. */
+ @Test
+ public void theLoginCookiesFileSitsInPrivateStorage() {
+ final File internal = new File("/data/user/0/com.httrack.android/files");
+ assertEquals(new File(internal, "logins/My Site.txt"),
+ StoragePaths.loginCookiesFile(internal, "My Site"));
+ }
+
+ @Test
+ public void noLoginPathIsBuiltFromAnUnusableName() {
+ final File internal = new File("/data/user/0/com.httrack.android/files");
+ assertNull(StoragePaths.loginCookiesFile(internal, null));
+ assertNull(StoragePaths.loginCookiesFile(internal, ""));
+ // A name that is not one segment must never become a path under our own storage.
+ assertNull(StoragePaths.loginCookiesFile(internal, ".."));
+ assertNull(StoragePaths.loginCookiesFile(internal, "a/b"));
+ assertNull(StoragePaths.loginCookiesFile(internal, "/etc/passwd"));
+ assertNull(StoragePaths.loginCookiesFile(null, "My Site"));
+ }
+
@Test
public void treatsAnEscapingPathAsOutsideTheRoot() throws IOException {
final String root = external.getCanonicalPath();
@@ -49,6 +121,10 @@ public void treatsAnEscapingPathAsOutsideTheRoot() throws IOException {
* before it opens the file. A call whose answer is dropped, or one made after the write, would
* leave the predicate right and the extraction wide open.
*/
+ /**
+ * The resource bundle is extracted entry by entry, and a ".." in a name would land outside the
+ * cache. Canonical paths are what the guard compares, so the escape must be built that way.
+ */
@Test
public void theResourceExtractionRefusesAnEscapingEntryBeforeWriting() throws IOException {
final String source = TestSources.withoutCommentsAndStrings(