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 @@ + + + + + + + + + + + + +