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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions app/src/main/java/com/httrack/android/HTTrackActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -1505,16 +1505,37 @@ protected void runInternal() {

// Result
if (code == 0) {
if (interrupted) {
final MirrorOutcome outcome = MirrorOutcome.of(interrupted,
engine.abortCode(), lastStats);
switch (outcome) {
case INTERRUPTED:
message = "<b>Interrupted</b>! (" + lastStats.errorsCount
+ " errors)";
} else if (lastStats.errorsCount == 0) {
break;
case ABORTED_FATAL:
message = "<b>Aborted</b>! (out of disk space, too many links, or"
+ " another fatal error)";
break;
case ABORTED_ROLLBACK:
message = "<b>Aborted</b>! (nothing was transferred, so the mirror"
+ " was left as it was)";
break;
case ABORTED_OTHER:
message = "<b>Aborted</b>! (the engine could not continue)";
break;
case SUCCESS:
message = "<b>Success</b>!";
} else if (lastStats.filesWritten != 0) {
break;
case SUCCESS_WITH_ERRORS:
message = "<b>Success</b>! (" + lastStats.errorsCount + " errors)";
} else {
break;
case FAILED:
message = "<b>Failed</b>! (" + lastStats.errorsCount
+ " errors, no files written)";
break;
default:
// No build-time check catches a new constant; a null message would ship as "null".
throw new IllegalStateException(outcome.name());
}
mirrorFolder = target;
message += "<br /><br />Mirror copied in <i><a href=\""
Expand Down
53 changes: 53 additions & 0 deletions app/src/main/java/com/httrack/android/MirrorOutcome.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.httrack.android;

import com.httrack.android.jni.HTTrackStats;

/**
* What a finished crawl actually was. Kept free of android.* so the choice can be tested; the
* wording that goes with each case belongs to the caller.
*/
enum MirrorOutcome {
/** The user asked for the stop. */
INTERRUPTED,
/** Out of room: a write that failed, or a link the table could not record. */
ABORTED_FATAL,
/** Nothing arrived, so the engine rolled the session back. */
ABORTED_ROLLBACK,
/** The engine gave up for a reason it does not name. */
ABORTED_OTHER,
SUCCESS,
SUCCESS_WITH_ERRORS,
/** Errors, and no file written. */
FAILED;

/** The values HTTrackLib.abortCode() reports; anything else is ABORTED_OTHER. */
static final int ABORT_NONE = 0;
static final int ABORT_FATAL = -1;
static final int ABORT_ROLLBACK = 2;

/**
* Weigh ABORTCODE, from HTTrackLib.abortCode(), against what the run recorded. INTERRUPTED must
* come first: a user stop sets the engine's abort flag two ways of its own, so the verdict alone
* reads it as an abort nobody asked for.
*/
static MirrorOutcome of(final boolean interrupted, final int abortCode,
final HTTrackStats stats) {
if (interrupted) {
return INTERRUPTED;
}
switch (abortCode) {
case ABORT_NONE:
break;
case ABORT_FATAL:
return ABORTED_FATAL;
case ABORT_ROLLBACK:
return ABORTED_ROLLBACK;
default:
return ABORTED_OTHER;
}
if (stats.errorsCount == 0) {
return SUCCESS;
}
return stats.filesWritten != 0 ? SUCCESS_WITH_ERRORS : FAILED;
}
}
9 changes: 9 additions & 0 deletions app/src/main/java/com/httrack/android/jni/HTTrackLib.java
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ public static int buildTopIndex(final File path, final File templatesPath) {
*/
public native boolean stop(boolean force);

/**
* How the engine aborted the last run, if it did. main() returns 0 for nearly every abort, so
* its return code alone cannot tell a mirror that died from one that finished.
*
* @return 0 when the mirror was not aborted, -1 on a fatal write such as a full disk, 1 when a
* callback refused to continue, 2 when nothing arrived and the previous session was restored
*/
public native int abortCode();

/**
* Was the last run cut short ? True for a stop() request, and for the size and time caps the
* engine enforces itself, which no return code of main() tells apart from a completion.
Expand Down
24 changes: 22 additions & 2 deletions app/src/main/jni/htslibjni.c
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,26 @@ Java_com_httrack_android_jni_HTTrackLib_stop(JNIEnv* env, jobject object,
return stopped;
}

JNICALL jint
Java_com_httrack_android_jni_HTTrackLib_abortCode(JNIEnv* env, jobject object) {
HTTrackLib_context *const context = getNativeOpt(env, object);
jint aborted = 0;

if (context == NULL) {
throwRuntimeException(env, "null context");
return 0;
}

MUTEX_LOCK(context->lock);
/* exit_xh's own value, not a flag: the three causes want three messages. */
if (context->opt != NULL) {
aborted = hts_is_exiting(context->opt);
}
MUTEX_UNLOCK(context->lock);

return aborted;
}

JNICALL jboolean
Java_com_httrack_android_jni_HTTrackLib_wasStopped(JNIEnv* env, jobject object) {
HTTrackLib_context *const context = getNativeOpt(env, object);
Expand All @@ -839,8 +859,8 @@ Java_com_httrack_android_jni_HTTrackLib_wasStopped(JNIEnv* env, jobject object)

MUTEX_LOCK(context->lock);
/* hts_main2() returns 0 for nearly every abort, so ask the engine instead.
stop is a cap or a user stop; exit_xh is a fatal disk error, a full link
table, an aborting callback, or a rolled-back session. */
stop is a cap or a user stop; exit_xh is a fatal disk error, an aborting
callback, or a rolled-back session. */
if (context->opt != NULL) {
stopped = context->opt->state.stop != 0
|| hts_is_exiting(context->opt) != 0 ? JNI_TRUE : JNI_FALSE;
Expand Down
43 changes: 43 additions & 0 deletions app/src/test/java/com/httrack/android/EngineAbortReportedTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.httrack.android;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import org.junit.Test;

/** MirrorOutcomeTest covers the choice; what it cannot reach is the wiring that feeds it. */
public class EngineAbortReportedTest {
private static String abortCodeBody() throws Exception {
return TestSources.between(TestSources.jniSource("htslibjni.c"), "HTTrackLib_abortCode", "\n}");
}

/** The value, not its truth: the causes carry different messages. */
@Test
public void abortCodeHandsBackTheEnginesOwnVerdict() throws Exception {
final String body = abortCodeBody();
assertTrue("abortCode must ask the engine", body.contains("hts_is_exiting"));
assertFalse("abortCode must not collapse the value to a flag",
body.matches("(?s).*hts_is_exiting\\([^)]*\\)\\s*(!=|==)\\s*0.*"));
assertFalse("abortCode must not collapse the value to a flag",
body.matches("(?s).*hts_is_exiting.*\\?.*:.*"));
}

/** Both inputs must reach the choice; the return code alone cannot see either. */
@Test
public void theFinishedPaneWeighsBothTheStopAndTheEnginesVerdict() throws Exception {
final String call = TestSources.between(TestSources.javaSource("HTTrackActivity"),
"MirrorOutcome.of(", ");");
assertTrue("the user's own stop must reach the choice", call.contains("interrupted"));
assertTrue("the engine's verdict must reach the choice", call.contains("engine.abortCode()"));
}

/** Swapping two abort messages is invisible to the enum, so each is pinned to its cause. */
@Test
public void eachAbortCauseKeepsItsOwnWording() throws Exception {
final String source = TestSources.javaSource("HTTrackActivity");
assertTrue("a fatal abort must name what ran out",
TestSources.between(source, "case ABORTED_FATAL:", "break;").contains("disk space"));
assertTrue("a rolled-back session must say the mirror was left alone",
TestSources.between(source, "case ABORTED_ROLLBACK:", "break;").contains("left as it was"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ private static List<String> messageStatements(final String source) {

/** The statement building the "Mirror copied in" line. */
private static String mirrorPathStatement(final String source) {
final int at = source.indexOf("Mirror copied in");
assertTrue("no mirror path line left in HTTrackActivity", at != -1);
return source.substring(at, source.indexOf(';', at));
return TestSources.between(source, "Mirror copied in", ";");
}

/** A base path is user-chosen, so an unescaped '&' or '<' would corrupt the render. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,8 @@ public void theCrawlStampsItsOwnOutcome() throws Exception {
two abort flags are pinned here: stop alone misses a fatal disk error. */
@Test
public void wasStoppedWeighsBothOfTheEnginesAbortFlags() throws Exception {
final String jni = TestSources.jniSource("htslibjni.c");
final int at = jni.indexOf("HTTrackLib_wasStopped");
assertTrue("wasStopped is gone", at > 0);
final String body = jni.substring(at, jni.indexOf("\n}", at));
final String body = TestSources.between(
TestSources.jniSource("htslibjni.c"), "HTTrackLib_wasStopped", "\n}");
assertTrue("wasStopped must read state.stop", body.contains("state.stop"));
assertTrue("wasStopped must also read exit_xh, which stop never sets",
body.contains("hts_is_exiting"));
Expand Down
61 changes: 61 additions & 0 deletions app/src/test/java/com/httrack/android/MirrorOutcomeTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.httrack.android;

import static org.junit.Assert.assertEquals;

import com.httrack.android.jni.HTTrackStats;
import org.junit.Test;

/** MirrorOutcome.of() has no android.* dependency, so unlike the pane it builds it can be run. */
public class MirrorOutcomeTest {
private static final int ABORT_CALLBACK = 1;
private static final int ABORT_UNKNOWN = 3;

private static HTTrackStats stats(final long errorsCount, final long filesWritten) {
final HTTrackStats stats = new HTTrackStats();
stats.errorsCount = errorsCount;
stats.filesWritten = filesWritten;
return stats;
}

private static void check(final MirrorOutcome expected, final boolean interrupted,
final int abortCode, final long errorsCount, final long filesWritten) {
assertEquals("interrupted=" + interrupted + " abortCode=" + abortCode + " errors="
+ errorsCount + " written=" + filesWritten, expected,
MirrorOutcome.of(interrupted, abortCode, stats(errorsCount, filesWritten)));
}

/**
* The engine sets its abort flag on its own for the two commonest ways a user stops a crawl: an
* early stop is rolled back for want of data (htscore.c:2088), and a forced stop refuses the loop
* callback (htscore.c:951). Weighing the abort first would report both as unwanted aborts.
*/
@Test
public void aStopTheUserAskedForIsNeverAnAbort() {
check(MirrorOutcome.INTERRUPTED, true, MirrorOutcome.ABORT_ROLLBACK, 0, 0);
check(MirrorOutcome.INTERRUPTED, true, ABORT_CALLBACK, 2, 40);
check(MirrorOutcome.INTERRUPTED, true, MirrorOutcome.ABORT_FATAL, 0, 7);
check(MirrorOutcome.INTERRUPTED, true, MirrorOutcome.ABORT_NONE, 0, 7);
}

/** main() returns 0 for these, so without the abort flag they would all read as a success. */
@Test
public void anAbortTheUserDidNotAskForIsNamedByItsCause() {
check(MirrorOutcome.ABORTED_FATAL, false, MirrorOutcome.ABORT_FATAL, 0, 3);
check(MirrorOutcome.ABORTED_ROLLBACK, false, MirrorOutcome.ABORT_ROLLBACK, 0, 0);
}

/** An abort code nobody has mapped must still abort, not fall through to success. */
@Test
public void anUnrecognisedAbortCodeIsStillAnAbort() {
check(MirrorOutcome.ABORTED_OTHER, false, ABORT_CALLBACK, 0, 0);
check(MirrorOutcome.ABORTED_OTHER, false, ABORT_UNKNOWN, 0, 40);
}

@Test
public void aCompletedRunIsStillJudgedOnItsErrorCount() {
check(MirrorOutcome.SUCCESS, false, MirrorOutcome.ABORT_NONE, 0, 40);
check(MirrorOutcome.SUCCESS, false, MirrorOutcome.ABORT_NONE, 0, 0);
check(MirrorOutcome.SUCCESS_WITH_ERRORS, false, MirrorOutcome.ABORT_NONE, 5, 40);
check(MirrorOutcome.FAILED, false, MirrorOutcome.ABORT_NONE, 5, 0);
}
}
14 changes: 14 additions & 0 deletions app/src/test/java/com/httrack/android/TestSources.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ static File engineFile(final String name) {
return new File(dir("src/main/jni/httrack"), name);
}

/** SOURCE from STARTMARKER up to the next ENDMARKER, which is left out. */
static String between(final String source, final String startMarker,
final String endMarker) {
final int from = source.indexOf(startMarker);
if (from == -1) {
throw new IllegalStateException("no " + startMarker);
}
final int to = source.indexOf(endMarker, from);
if (to == -1) {
throw new IllegalStateException(startMarker + " has no " + endMarker);
}
return source.substring(from, to);
}

static int occurrences(final String source, final String text) {
int count = 0;
for (int at = source.indexOf(text); at != -1; at = source.indexOf(text,
Expand Down
Loading