From 51d6fceb7d90c6bba4671aaff647dc9de7ed32ef Mon Sep 17 00:00:00 2001 From: hamza-lzr Date: Mon, 31 Aug 2026 10:46:22 +0100 Subject: [PATCH 1/4] Add history export restore support --- app/build.gradle.kts | 1 + .../db/room/HistoryImportTest.java | 178 ++++++++++++ ...ettingTagsInWhenTheListIsNotEmptyTest.java | 25 ++ ...ImportingHistoryFromTheDeviceListTest.java | 263 ++++++++++++++++++ .../opentagviewer/MyDevicesListActivity.java | 114 +++++++- .../db/room/OpenTagViewerDatabase.java | 2 + .../db/room/dao/HistoryImportDao.java | 91 ++++++ .../util/export/HistoryCsvWriter.java | 26 +- .../util/export/HistoryExportEntry.java | 18 ++ .../util/export/HistoryZipWriter.java | 13 +- .../util/history/HistoryImportException.java | 29 ++ .../util/history/HistoryImportResult.java | 36 +++ .../util/history/HistoryImportRow.java | 15 + .../util/history/HistoryImportSink.java | 13 + .../util/history/HistoryImporter.java | 204 ++++++++++++++ app/src/main/res/menu/my_devices_menu.xml | 10 +- app/src/main/res/values-de/strings.xml | 10 + app/src/main/res/values-en/strings.xml | 10 + app/src/main/res/values-fr/strings.xml | 10 + app/src/main/res/values-ja/strings.xml | 10 + app/src/main/res/values-ko/strings.xml | 10 + app/src/main/res/values-nl/strings.xml | 10 + app/src/main/res/values-ru/strings.xml | 10 + app/src/main/res/values-zh-rCN/strings.xml | 10 + app/src/main/res/values-zh-rTW/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + .../util/export/HistoryCsvWriterTest.java | 13 +- .../util/export/HistoryZipWriterTest.java | 33 ++- .../util/history/HistoryImporterTest.java | 244 ++++++++++++++++ gradle/libs.versions.toml | 3 + 30 files changed, 1387 insertions(+), 44 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryExportEntry.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportResult.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportRow.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afd1dff8..b4d3d9f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -518,6 +518,7 @@ dependencies { // 注意:3D地图SDK已包含定位功能,无需单独引入location SDK implementation(libs.amap.map3d) implementation(libs.zip4j) + implementation(libs.commons.csv) testImplementation(libs.junit) testImplementation(libs.android.room.testing) diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java new file mode 100644 index 00000000..b0bcde3f --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java @@ -0,0 +1,178 @@ +package dev.wander.android.opentagviewer.db.room; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.time.ZoneOffset; +import java.util.List; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.util.export.HistoryExportEntry; +import dev.wander.android.opentagviewer.util.export.HistoryZipWriter; +import dev.wander.android.opentagviewer.util.history.HistoryImportResult; +import dev.wander.android.opentagviewer.util.history.HistoryImportException; +import dev.wander.android.opentagviewer.util.history.HistoryImporter; + +/** The restore interface joined to a real transactional Room database. */ +@RunWith(AndroidJUnit4.class) +public class HistoryImportTest { + private static final String TAG_A = "tag-a"; + private static final String TAG_B = "tag-b"; + private static final String UNKNOWN_TAG = "unknown-tag"; + private static final String REMOVED_TAG = "removed-tag"; + + private OpenTagViewerDatabase db; + + @Before + public void openDatabase() { + this.db = Room.inMemoryDatabaseBuilder( + getInstrumentation().getTargetContext(), OpenTagViewerDatabase.class) + .allowMainThreadQueries() + .build(); + this.db.ownedBeaconDao().insertAll(own(TAG_A), own(TAG_B), own(REMOVED_TAG)); + this.db.ownedBeaconDao().setRemoved(REMOVED_TAG); + } + + @After + public void closeDatabase() { + this.db.close(); + } + + @Test + public void aSecondImportPreservesExistingRowsAndOnlyAddsTheOverlap() throws Exception { + final HistoryImportResult first = importHistory(entry(TAG_A, + report(1_000L, "first"), report(2_000L, "existing"))); + final HistoryImportResult second = importHistory(entry(TAG_A, + report(2_000L, "replacement"), report(3_000L, "new"))); + + assertCounts(first, 2, 2, 0, 0); + assertCounts(second, 2, 1, 1, 0); + final List stored = this.db.locationReportDao() + .getInTimeRange(TAG_A, 0, Long.MAX_VALUE); + assertEquals(3, stored.size()); + assertEquals("existing", stored.get(1).description); + + final HistoryImportResult third = importHistory(entry(TAG_A, + report(1_000L, "ignored"), report(2_000L, "ignored"), + report(3_000L, "ignored"))); + assertCounts(third, 3, 0, 3, 0); + } + + @Test + public void equalTimestampsBelongIndependentlyToDifferentBeacons() throws Exception { + final HistoryImportResult result = importHistory( + entry(TAG_A, report(4_000L, "a")), + entry(TAG_B, report(4_000L, "b"))); + + assertCounts(result, 2, 2, 0, 0); + assertEquals(4_000L, this.db.locationReportDao().getLastFor(TAG_A).timestamp); + assertEquals(4_000L, this.db.locationReportDao().getLastFor(TAG_B).timestamp); + } + + @Test + public void firstRepeatedTimestampInTheArchiveWins() throws Exception { + final HistoryImportResult result = importHistory(entry(TAG_A, + report(5_000L, "first"), report(5_000L, "second"))); + + assertCounts(result, 2, 1, 1, 0); + assertEquals("first", this.db.locationReportDao().getLastFor(TAG_A).description); + } + + @Test + public void unknownAndRemovedTagsAreSkippedAndAnImportedTagCanBeRetried() throws Exception { + final HistoryExportEntry unknown = entry(UNKNOWN_TAG, report(6_000L, "unknown")); + final HistoryImportResult skipped = importHistory( + unknown, entry(REMOVED_TAG, report(7_000L, "removed"))); + + assertCounts(skipped, 2, 0, 0, 2); + + this.db.ownedBeaconDao().insertIfNew(own(UNKNOWN_TAG)); + final HistoryImportResult retried = importHistory(unknown); + + assertCounts(retried, 1, 1, 0, 0); + assertEquals(6_000L, + this.db.locationReportDao().getLastFor(UNKNOWN_TAG).timestamp); + } + + @Test + public void aDatabaseFailureRollsBackRowsAlreadyInsertedByTheImport() throws Exception { + this.db.getOpenHelper().getWritableDatabase().execSQL( + "CREATE TRIGGER reject_second_import_row " + + "BEFORE INSERT ON LocationReport " + + "WHEN NEW.timestamp = 9000 " + + "BEGIN SELECT RAISE(ABORT, 'deliberate import failure'); END"); + + final HistoryImportException error = assertThrows( + HistoryImportException.class, + () -> importHistory(entry(TAG_A, + report(8_000L, "would have been inserted"), + report(9_000L, "forces rollback")))); + + assertEquals(HistoryImportException.Reason.DATABASE_FAILED, error.getReason()); + assertEquals(0, this.db.locationReportDao() + .getInTimeRange(TAG_A, 0, Long.MAX_VALUE).size()); + } + + private HistoryImportResult importHistory(final HistoryExportEntry... entries) + throws Exception { + final ByteArrayOutputStream archive = new ByteArrayOutputStream(); + new HistoryZipWriter(ZoneOffset.UTC).write(archive, List.of(entries)); + return new HistoryImporter(this.db).importArchive( + new ByteArrayInputStream(archive.toByteArray())); + } + + private static HistoryExportEntry entry( + final String beaconId, final BeaconLocationReport... reports) { + return new HistoryExportEntry(beaconId, beaconId, List.of(reports)); + } + + private static BeaconLocationReport report(final long timestamp, final String description) { + return BeaconLocationReport.builder() + .timestamp(timestamp) + .publishedAt(timestamp + 1) + .latitude(1.25) + .longitude(2.5) + .horizontalAccuracy(10) + .confidence(1) + .status(0) + .description(description) + .build(); + } + + private static OwnedBeacon own(final String id) { + return OwnedBeacon.builder() + .id(id) + .content("{}") + .accessoryJson("{\"type\":\"accessory\"}") + .version("0.0.2") + .fromAccount(false) + .isRemoved(false) + .build(); + } + + private static void assertCounts( + final HistoryImportResult actual, + final int read, + final int added, + final int alreadyPresent, + final int unknown) { + assertEquals(read, actual.getRowsRead()); + assertEquals(added, actual.getRowsAdded()); + assertEquals(alreadyPresent, actual.getRowsAlreadyPresent()); + assertEquals(0, actual.getRowsMalformed()); + assertEquals(unknown, actual.getRowsSkippedUnknownBeacon()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/GettingTagsInWhenTheListIsNotEmptyTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/GettingTagsInWhenTheListIsNotEmptyTest.java index 727c39a5..9ff7fc37 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/GettingTagsInWhenTheListIsNotEmptyTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/GettingTagsInWhenTheListIsNotEmptyTest.java @@ -5,12 +5,17 @@ import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra; import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.not; import android.app.Activity; @@ -111,6 +116,8 @@ public void seedOneTagSoTheListIsNotEmpty() { // at the door rather than launched. Intents.intending(hasComponent(FetchFromICloudActivity.class.getName())) .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)); + intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)) + .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)); } @After @@ -168,6 +175,24 @@ public void theoverflowMenuOffersBothWaysIn() { .inRoot(isPlatformPopup()).check(matches(isDisplayed()))); onView(withText(R.string.icloud_import_from_file)) .inRoot(isPlatformPopup()).check(matches(isDisplayed())); + onView(withText(R.string.import_history)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed())); + } + + @Test + public void importingHistoryAsksTheSystemForAZipAndCancellationIsSilent() { + this.openTheListWithATagInIt(); + + onView(withId(R.id.page_menu_button)).perform(click()); + Eventually.check(() -> onView(withText(R.string.import_history)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed()))); + onView(withText(R.string.import_history)).inRoot(isPlatformPopup()).perform(click()); + + Eventually.check(() -> intended(allOf( + hasAction(Intent.ACTION_OPEN_DOCUMENT), + hasExtra(Intent.EXTRA_MIME_TYPES, arrayContaining("application/zip"))))); + onView(withText(R.string.history_import_complete_title)).check(doesNotExist()); + onView(withText(R.string.history_import_failed_title)).check(doesNotExist()); } /** diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java new file mode 100644 index 00000000..30b8d09a --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java @@ -0,0 +1,263 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBackUnconditionally; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MyDevicesListActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; +import dev.wander.android.opentagviewer.db.room.entity.Import; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.util.export.HistoryCsvWriter; + +/** The history picker and every result the My Devices screen promises to explain. */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ImportingHistoryFromTheDeviceListTest { + private static final String TAG_ID = "history-import-ui-tag"; + private static final String TAG_NAME = "History Import UI Tag"; + private static final String UNKNOWN_ID = "history-import-ui-unknown"; + private static final String SOURCE_USER = "history-import-ui@example.invalid"; + private static final long TIMESTAMP = + Instant.parse("2026-08-15T12:34:56Z").toEpochMilli(); + private static final String PLIST = "" + + "" + + "batteryLevel1" + + "model" + + "pairingDate2025-02-27T20:03:32Z" + + "privateKeykey" + + "databm90LWEtcmVhbC1rZXk=" + + "productId21760" + + "stableIdentifier2001~#0~#A0" + + "systemVersion2.0.73" + + "vendorId76" + + ""; + + private final List files = new ArrayList<>(); + private OpenTagViewerDatabase db; + private ActivityScenario scenario; + + @Before + public void seedOneKnownTag() { + final Context context = getInstrumentation().getTargetContext(); + this.db = OpenTagViewerDatabase.getInstance(context); + this.forgetTestData(); + + final long importId = this.db.importDao().insert(Import.builder() + .version("0.0.2") + .importedAt(1_700_000_000_000L) + .exportedAt(1_699_000_000_000L) + .sourceUser(SOURCE_USER) + .exportedVia("OpenTagViewer.wizard:test") + .build()); + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(TAG_ID).importId(importId).content(PLIST).version("0.0.2") + .fromAccount(false).isRemoved(false).build()); + this.db.beaconNamingRecordDao().insertAll(BeaconNamingRecord.builder() + .id(TAG_ID).importId(importId).version("0.0.2").isRemoved(false) + .content("" + + "" + + "identifier" + TAG_ID + "" + + "name" + TAG_NAME + "" + + "") + .build()); + + Intents.init(); + } + + @After + public void cleanUp() { + if (this.scenario != null) { + this.scenario.close(); + } + Intents.release(); + this.forgetTestData(); + for (File file : this.files) { + file.delete(); + } + } + + @Test + public void validArchiveReportsEveryCountRefreshesTheRowAndTellsTheMap() throws Exception { + final String header = String.join(",", HistoryCsvWriter.requiredHeaders()); + final String csv = header + "\r\n" + + row(TAG_ID, TIMESTAMP, "52.3702157", "added") + "\r\n" + + row(TAG_ID, TIMESTAMP, "52.3702157", "duplicate") + "\r\n" + + row(TAG_ID, TIMESTAMP + 1, "bad-latitude", "malformed") + "\r\n" + + row(UNKNOWN_ID, TIMESTAMP + 2, "52.3702157", "unknown") + "\r\n"; + this.answerPickerWith(zip("history.csv", csv)); + this.openAndChooseHistory(); + + final Context context = getInstrumentation().getTargetContext(); + final String counts = context.getString( + R.string.history_import_result_counts, 4, 1, 1, 1, 1); + Eventually.check(() -> onView(withText(containsString(counts))).inRoot(isDialog()) + .check(matches(isDisplayed()))); + onView(withText(containsString(context.getString( + R.string.history_import_unknown_guidance)))) + .inRoot(isDialog()) + .check(matches(isDisplayed())); + onView(withText(R.string.ok)).inRoot(isDialog()).perform(click()); + + Eventually.check(() -> onView(allOf( + withId(R.id.device_item_container), hasDescendant(withText(TAG_NAME)))) + .check(matches(not(hasDescendant(withText(R.string.no_last_location_known)))))); + + pressBackUnconditionally(); + Eventually.check(() -> assertEquals( + Activity.RESULT_OK, this.scenario.getResult().getResultCode())); + assertTrue(this.scenario.getResult().getResultData() + .getBooleanExtra("isDeviceListChanged", false)); + } + + @Test + public void legacyNameOnlyExportExplainsWhyItIsUnsafe() throws Exception { + final List currentHeaders = HistoryCsvWriter.requiredHeaders(); + final String legacyHeader = String.join(",", + currentHeaders.subList(0, currentHeaders.size() - 1)); + this.answerPickerWith(zip("Wallet.csv", legacyHeader + "\r\n")); + this.openAndChooseHistory(); + + Eventually.check(() -> onView(withText(R.string.history_import_legacy_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withText(R.string.history_import_legacy_message)).inRoot(isDialog()) + .check(matches(isDisplayed())); + } + + @Test + public void damagedOrUnsupportedArchiveGetsItsOwnExplanation() throws Exception { + this.answerPickerWith(zip("readme.txt", "not an Android history export")); + this.openAndChooseHistory(); + + Eventually.check(() -> onView(withText(R.string.history_import_invalid_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withText(R.string.history_import_invalid_message)).inRoot(isDialog()) + .check(matches(isDisplayed())); + } + + @Test + public void readFailureUsesTheGenericFailureMessage() { + final File missing = new File( + getInstrumentation().getTargetContext().getCacheDir(), + "history-import-file-that-does-not-exist.zip"); + missing.delete(); + this.answerPickerWith(missing); + this.openAndChooseHistory(); + + Eventually.check(() -> onView(withText(R.string.history_import_failed_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withText(R.string.history_import_failed_message)).inRoot(isDialog()) + .check(matches(isDisplayed())); + } + + @Test + public void cancellingThePickerChangesNeitherHistoryNorTheMapResult() { + intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)) + .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)); + + this.openAndChooseHistory(); + + assertEquals(0, this.db.locationReportDao() + .getInTimeRange(TAG_ID, 0, Long.MAX_VALUE).size()); + onView(withText(R.string.history_import_complete_title)).check(doesNotExist()); + onView(withText(R.string.history_import_failed_title)).check(doesNotExist()); + + pressBackUnconditionally(); + Eventually.check(() -> assertEquals( + Activity.RESULT_OK, this.scenario.getResult().getResultCode())); + assertFalse(this.scenario.getResult().getResultData() + .getBooleanExtra("isDeviceListChanged", false)); + } + + private void openAndChooseHistory() { + this.scenario = ActivityScenario.launchActivityForResult(MyDevicesListActivity.class); + Eventually.check(() -> onView(withText(TAG_NAME)).check(matches(isDisplayed()))); + onView(withId(R.id.page_menu_button)).perform(click()); + Eventually.check(() -> onView(withText(R.string.import_history)) + .inRoot(isPlatformPopup()).check(matches(isDisplayed()))); + onView(withText(R.string.import_history)).inRoot(isPlatformPopup()).perform(click()); + } + + private void answerPickerWith(final File file) { + final Intent result = new Intent().setData(Uri.fromFile(file)); + intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)) + .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_OK, result)); + } + + private File zip(final String entryName, final String contents) throws Exception { + final File file = File.createTempFile( + "history-import-", ".zip", + getInstrumentation().getTargetContext().getCacheDir()); + this.files.add(file); + try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(file))) { + zip.putNextEntry(new ZipEntry(entryName)); + zip.write(contents.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return file; + } + + private static String row( + final String beaconId, + final long timestamp, + final String latitude, + final String description) { + return "2026-08-15T12:34:56Z,2026-08-15 12:34:56Z," + timestamp + "," + + latitude + ",4.8951679,12,2,1,2026-08-15T12:35:56Z," + + description + "," + (timestamp + 60_000L) + "," + latitude + + ",4.8951679,true," + beaconId; + } + + private void forgetTestData() { + this.db.beaconNamingRecordDao().delete(BeaconNamingRecord.builder().id(TAG_ID).build()); + this.db.ownedBeaconDao().delete(OwnedBeacon.builder().id(TAG_ID).build()); + for (Import stale : this.db.importDao().getImportsFromUser(SOURCE_USER)) { + this.db.importDao().delete(stale); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index bb21f65a..ff7ddbe2 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -28,16 +28,16 @@ import androidx.recyclerview.widget.RecyclerView; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Set; import java.util.Map; +import java.util.Set; import java.util.stream.IntStream; import dev.wander.android.opentagviewer.data.model.BeaconInformation; @@ -63,7 +63,11 @@ import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.android.WebLink; +import dev.wander.android.opentagviewer.util.export.HistoryExportEntry; import dev.wander.android.opentagviewer.util.export.HistoryZipWriter; +import dev.wander.android.opentagviewer.util.history.HistoryImportException; +import dev.wander.android.opentagviewer.util.history.HistoryImportResult; +import dev.wander.android.opentagviewer.util.history.HistoryImporter; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; @@ -132,6 +136,15 @@ public class MyDevicesListActivity extends AppCompatActivity { } ); + private final ActivityResultLauncher openHistoryZipLauncher = + registerForActivityResult( + new ActivityResultContracts.OpenDocument(), + uri -> { + if (uri != null) { + this.importHistory(uri); + } + }); + /** * Reading the account, which can end by asking for the file picker instead. * @@ -346,6 +359,10 @@ private void refreshListOnBeaconChanged(final String beaconId) { protected void onResume() { super.onResume(); + this.refreshLatestLocations(); + } + + private void refreshLatestLocations() { if (this.beaconInfo.isEmpty()) { return; } @@ -635,12 +652,87 @@ private void showPageMenu() { this.handleStartImport(); return true; } + if (id == R.id.action_import_history) { + this.openHistoryZipLauncher.launch(new String[]{"application/zip"}); + return true; + } return false; }); menu.show(); } + private void importHistory(@NonNull final Uri uri) { + Observable.fromCallable(() -> { + final InputStream opened = this.getContentResolver().openInputStream(uri); + if (opened == null) { + throw new IOException("The document provider returned no history data"); + } + return new HistoryImporter(OpenTagViewerDatabase.getInstance( + this.getApplicationContext())).importArchive(opened); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::historyImported, this::historyImportFailed); + } + + private void historyImported(@NonNull final HistoryImportResult result) { + if (result.getRowsAdded() > 0) { + this.devicesListChanged = true; + this.refreshLatestLocations(); + } + + String message = this.getString( + R.string.history_import_result_counts, + result.getRowsRead(), + result.getRowsAdded(), + result.getRowsAlreadyPresent(), + result.getRowsMalformed(), + result.getRowsSkippedUnknownBeacon()); + if (result.getRowsSkippedUnknownBeacon() > 0) { + message += "\n\n" + this.getString(R.string.history_import_unknown_guidance); + } + + new MaterialAlertDialogBuilder( + this, + com.google.android.material.R.style + .ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.history_import_complete_title) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show(); + } + + private void historyImportFailed(@NonNull final Throwable error) { + Log.e(TAG, "Could not import history", error); + + final int title; + final int message; + if (error instanceof HistoryImportException + && ((HistoryImportException) error).getReason() + == HistoryImportException.Reason.UNSUPPORTED_LEGACY) { + title = R.string.history_import_legacy_title; + message = R.string.history_import_legacy_message; + } else if (error instanceof HistoryImportException + && ((HistoryImportException) error).getReason() + == HistoryImportException.Reason.INVALID_ARCHIVE) { + title = R.string.history_import_invalid_title; + message = R.string.history_import_invalid_message; + } else { + title = R.string.history_import_failed_title; + message = R.string.history_import_failed_message; + } + + new MaterialAlertDialogBuilder( + this, + com.google.android.material.R.style + .ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show(); + } + /** * Read the account now, because somebody asked. * @@ -877,24 +969,16 @@ private void writeHistoryZip(final Uri destination, final List Pair.create(beacon.getName(), reports))) + .map(reports -> new HistoryExportEntry( + beacon.getBeaconId(), beacon.getName(), reports))) .toList() - .map(pairs -> { - // LinkedHashMap: entry order follows the order shown on screen, which is - // the order the user picked them in. - Map> byName = new LinkedHashMap<>(); - for (var pair : pairs) { - byName.put(pair.first, pair.second); - } - return byName; - }) .subscribeOn(Schedulers.io()) - .subscribe(historyByName -> { + .subscribe(histories -> { try (OutputStream out = this.getContentResolver().openOutputStream(destination)) { if (out == null) { throw new IOException("the picker returned nothing to write to"); } - new HistoryZipWriter(ZoneId.systemDefault()).write(out, historyByName); + new HistoryZipWriter(ZoneId.systemDefault()).write(out, histories); } this.runOnUiThread(() -> { @@ -1044,4 +1128,4 @@ private void updateEmptyState() { findViewById(R.id.my_devices_empty_state).setVisibility(isEmpty ? VISIBLE : GONE); findViewById(R.id.my_devices_list).setVisibility(isEmpty ? GONE : VISIBLE); } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java index cdf7f2a6..9dfc43a3 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java @@ -11,6 +11,7 @@ import dev.wander.android.opentagviewer.db.room.dao.BeaconNamingRecordDao; import dev.wander.android.opentagviewer.db.room.dao.DailyHistoryFetchRecordDao; +import dev.wander.android.opentagviewer.db.room.dao.HistoryImportDao; import dev.wander.android.opentagviewer.db.room.dao.ImportDao; import dev.wander.android.opentagviewer.db.room.dao.LocationReportDao; import dev.wander.android.opentagviewer.db.room.dao.OwnedBeaconDao; @@ -153,6 +154,7 @@ public static OpenTagViewerDatabase getInstance(Context context) { } public abstract ImportDao importDao(); + public abstract HistoryImportDao historyImportDao(); public abstract BeaconNamingRecordDao beaconNamingRecordDao(); public abstract OwnedBeaconDao ownedBeaconDao(); public abstract LocationReportDao locationReportDao(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java new file mode 100644 index 00000000..e56540f9 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java @@ -0,0 +1,91 @@ +package dev.wander.android.opentagviewer.db.room.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Transaction; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; +import dev.wander.android.opentagviewer.util.BeaconLocationReportHasher; +import dev.wander.android.opentagviewer.util.history.HistoryImportResult; +import dev.wander.android.opentagviewer.util.history.HistoryImportRow; + +/** The atomic database half of restoring a history archive. */ +@Dao +public interface HistoryImportDao { + @Query("SELECT EXISTS(SELECT 1 FROM OwnedBeacons" + + " WHERE id = :beaconId AND is_removed = 0)") + boolean isActiveBeacon(String beaconId); + + @Query("SELECT timestamp FROM LocationReport WHERE beacon_id = :beaconId") + List timestampsFor(String beaconId); + + @Insert(onConflict = OnConflictStrategy.IGNORE) + long insert(LocationReport report); + + /** + * Existing data wins. The first valid row in the archive wins among repeated timestamps. + * Everything is one transaction so a database failure leaves no partial restore behind. + */ + @Transaction + default HistoryImportResult merge( + final List rows, + final int rowsRead, + final int malformedRows, + final long now) { + + final Map activeBeacons = new HashMap<>(); + final Map> heldTimestamps = new HashMap<>(); + int added = 0; + int alreadyPresent = 0; + int unknown = 0; + + for (HistoryImportRow row : rows) { + final String beaconId = row.getBeaconId(); + final boolean active = activeBeacons.computeIfAbsent( + beaconId, this::isActiveBeacon); + if (!active) { + unknown++; + continue; + } + + final Set timestamps = heldTimestamps.computeIfAbsent( + beaconId, id -> new HashSet<>(this.timestampsFor(id))); + if (!timestamps.add(row.getReport().getTimestamp())) { + alreadyPresent++; + continue; + } + + final LocationReport stored = LocationReport.builder() + .hashId(BeaconLocationReportHasher.getSha256HashFor( + beaconId, row.getReport())) + .beaconId(beaconId) + .publishedAt(row.getReport().getPublishedAt()) + .description(row.getReport().getDescription()) + .timestamp(row.getReport().getTimestamp()) + .confidence(row.getReport().getConfidence()) + .latitude(row.getReport().getLatitude()) + .longitude(row.getReport().getLongitude()) + .horizontalAccuracy(row.getReport().getHorizontalAccuracy()) + .status(row.getReport().getStatus()) + .lastUpdate(now) + .build(); + + if (this.insert(stored) == -1L) { + alreadyPresent++; + } else { + added++; + } + } + + return new HistoryImportResult( + rowsRead, added, alreadyPresent, malformedRows, unknown); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java index 1ded4f08..ed602f02 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java @@ -50,8 +50,22 @@ public final class HistoryCsvWriter { "status", "published_at_utc", "description", + // Preserve the spreadsheet-facing formatting above while keeping restoration exact. + "published_at_epoch_ms", + "latitude_exact", + "longitude_exact", + "description_present", + // Last because it is for restoring this file, not for reading it in a spreadsheet. + // Stable identity must live in the contents: display names and filenames can both + // change, and two tags may have the same one. + "beacon_id", }; + /** The immutable file contract used by the restore side to validate a CSV. */ + public static List requiredHeaders() { + return List.of(HEADERS); + } + private static final DateTimeFormatter UTC = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneId.of("UTC")); @@ -75,6 +89,7 @@ public HistoryCsvWriter(@NonNull final ZoneId localZone) { */ public void write( @NonNull final Writer out, + @NonNull final String beaconId, @NonNull final List reports) throws IOException { out.write(String.join(",", HEADERS)); @@ -84,7 +99,7 @@ public void write( .sorted((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp())) .forEach(report -> { try { - out.write(toRow(report)); + out.write(toRow(beaconId, report)); out.write(NEWLINE); } catch (IOException e) { throw new UncheckedWriteFailure(e); @@ -92,7 +107,7 @@ public void write( }); } - private String toRow(final BeaconLocationReport report) { + private String toRow(final String beaconId, final BeaconLocationReport report) { final Instant recorded = Instant.ofEpochMilli(report.getTimestamp()); return String.join(",", @@ -107,7 +122,12 @@ private String toRow(final BeaconLocationReport report) { Long.toString(report.getConfidence()), Long.toString(report.getStatus()), UTC.format(Instant.ofEpochMilli(report.getPublishedAt())), - escape(report.getDescription())); + escape(report.getDescription()), + Long.toString(report.getPublishedAt()), + Double.toString(report.getLatitude()), + Double.toString(report.getLongitude()), + Boolean.toString(report.getDescription() != null), + escape(beaconId)); } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryExportEntry.java b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryExportEntry.java new file mode 100644 index 00000000..0c7ca866 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryExportEntry.java @@ -0,0 +1,18 @@ +package dev.wander.android.opentagviewer.util.export; + +import androidx.annotation.NonNull; + +import java.util.List; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** One tag and the reports that belong to it in a history export. */ +@Getter +@AllArgsConstructor +public final class HistoryExportEntry { + @NonNull private final String beaconId; + @NonNull private final String displayName; + @NonNull private final List reports; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriter.java index ea005cf6..cd61d02d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriter.java @@ -12,7 +12,6 @@ import java.time.format.DateTimeFormatter; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -51,12 +50,11 @@ public HistoryZipWriter(@NonNull final ZoneId localZone) { *

The stream is not closed - whoever opened it closes it, which for the storage picker * means the try-with-resources that produced it. * - * @param historyByTagName reports keyed by the name to show the user, which is the - * user's own name for the tag rather than an identifier + * @param histories ordered tags with both their stable identity and user-visible name */ public void write( @NonNull final OutputStream out, - @NonNull final Map> historyByTagName) + @NonNull final List histories) throws IOException { final Set used = new HashSet<>(); @@ -65,8 +63,9 @@ public void write( // and the caller owns that. final ZipOutputStream zip = new ZipOutputStream(out); - for (Map.Entry> tag : historyByTagName.entrySet()) { - final String entryName = uniqueEntryName(used, tag.getKey(), tag.getValue()); + for (HistoryExportEntry tag : histories) { + final String entryName = uniqueEntryName( + used, tag.getDisplayName(), tag.getReports()); zip.putNextEntry(new ZipEntry(entryName)); @@ -74,7 +73,7 @@ public void write( // here would close the zip with it, and the encoding has to be explicit anyway - // the default charset is the platform's, and these files travel. Writer writer = new OutputStreamWriter(zip, StandardCharsets.UTF_8); - this.csvWriter.write(writer, tag.getValue()); + this.csvWriter.write(writer, tag.getBeaconId(), tag.getReports()); writer.flush(); zip.closeEntry(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java new file mode 100644 index 00000000..5b522eed --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java @@ -0,0 +1,29 @@ +package dev.wander.android.opentagviewer.util.history; + +import lombok.Getter; + +/** A whole-archive failure, distinct from one malformed row that can be skipped. */ +@Getter +public final class HistoryImportException extends Exception { + public enum Reason { + UNSUPPORTED_LEGACY, + INVALID_ARCHIVE, + READ_FAILED, + DATABASE_FAILED, + } + + private final Reason reason; + + public HistoryImportException(final Reason reason, final String message) { + super(message); + this.reason = reason; + } + + public HistoryImportException( + final Reason reason, + final String message, + final Throwable cause) { + super(message, cause); + this.reason = reason; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportResult.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportResult.java new file mode 100644 index 00000000..ef18c53c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportResult.java @@ -0,0 +1,36 @@ +package dev.wander.android.opentagviewer.util.history; + +import lombok.Getter; + +/** Mutually-exclusive outcomes for every data row encountered in a history archive. */ +@Getter +public final class HistoryImportResult { + private final int rowsRead; + private final int rowsAdded; + private final int rowsAlreadyPresent; + private final int rowsMalformed; + private final int rowsSkippedUnknownBeacon; + + public HistoryImportResult( + final int rowsRead, + final int rowsAdded, + final int rowsAlreadyPresent, + final int rowsMalformed, + final int rowsSkippedUnknownBeacon) { + + if (rowsRead < 0 || rowsAdded < 0 || rowsAlreadyPresent < 0 + || rowsMalformed < 0 || rowsSkippedUnknownBeacon < 0) { + throw new IllegalArgumentException("history import counts cannot be negative"); + } + if (rowsRead != rowsAdded + rowsAlreadyPresent + + rowsMalformed + rowsSkippedUnknownBeacon) { + throw new IllegalArgumentException("every history row must have exactly one outcome"); + } + + this.rowsRead = rowsRead; + this.rowsAdded = rowsAdded; + this.rowsAlreadyPresent = rowsAlreadyPresent; + this.rowsMalformed = rowsMalformed; + this.rowsSkippedUnknownBeacon = rowsSkippedUnknownBeacon; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportRow.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportRow.java new file mode 100644 index 00000000..dc74c461 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportRow.java @@ -0,0 +1,15 @@ +package dev.wander.android.opentagviewer.util.history; + +import androidx.annotation.NonNull; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** A valid CSV row, before the database decides whether its beacon exists. */ +@Getter +@AllArgsConstructor +public final class HistoryImportRow { + @NonNull private final String beaconId; + @NonNull private final BeaconLocationReport report; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java new file mode 100644 index 00000000..1f2e3945 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java @@ -0,0 +1,13 @@ +package dev.wander.android.opentagviewer.util.history; + +import java.util.List; + +/** Internal seam: Room in production, an in-memory capture in the JVM parser tests. */ +@FunctionalInterface +interface HistoryImportSink { + HistoryImportResult merge( + List rows, + int rowsRead, + int malformedRows, + long now); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java new file mode 100644 index 00000000..d4ab2e58 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java @@ -0,0 +1,204 @@ +package dev.wander.android.opentagviewer.util.history; + +import androidx.annotation.NonNull; + +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVException; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.apache.commons.csv.DuplicateHeaderMode; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.LongSupplier; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipInputStream; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.util.export.HistoryCsvWriter; + +/** Restores an Android history-export ZIP through one blocking interface. */ +public final class HistoryImporter { + private static final String BEACON_ID = "beacon_id"; + + private static final CSVFormat CSV = CSVFormat.RFC4180.builder() + .setHeader() + .setSkipHeaderRecord(true) + .setDuplicateHeaderMode(DuplicateHeaderMode.DISALLOW) + .get(); + + private final HistoryImportSink sink; + private final LongSupplier clock; + + public HistoryImporter(@NonNull final OpenTagViewerDatabase db) { + this(db.historyImportDao()::merge, System::currentTimeMillis); + } + + HistoryImporter( + @NonNull final HistoryImportSink sink, + @NonNull final LongSupplier clock) { + this.sink = sink; + this.clock = clock; + } + + /** + * Consumes and closes {@code archive}. The caller must run this off the main thread. + */ + public HistoryImportResult importArchive(@NonNull final InputStream archive) + throws HistoryImportException { + + final ReadResult read = this.readArchive(archive); + try { + return this.sink.merge( + read.rows, read.rowsRead, read.malformedRows, this.clock.getAsLong()); + } catch (RuntimeException error) { + throw new HistoryImportException( + HistoryImportException.Reason.DATABASE_FAILED, + "Room could not merge the history archive", + error); + } + } + + private ReadResult readArchive(final InputStream archive) throws HistoryImportException { + final ReadResult result = new ReadResult(); + int csvEntries = 0; + + try (ZipInputStream zip = new ZipInputStream(archive, StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory() + || !entry.getName().toLowerCase(Locale.ROOT).endsWith(".csv")) { + continue; + } + + csvEntries++; + this.readCsvEntry(zip, result); + } + } catch (HistoryImportException error) { + throw error; + } catch (CSVException | ZipException | UncheckedIOException error) { + throw new HistoryImportException( + HistoryImportException.Reason.INVALID_ARCHIVE, + "History ZIP or CSV is damaged", + error); + } catch (IOException error) { + throw new HistoryImportException( + HistoryImportException.Reason.READ_FAILED, + "History archive could not be read", + error); + } catch (RuntimeException error) { + // Commons CSV reports some malformed record shapes while its iterator advances. + throw new HistoryImportException( + HistoryImportException.Reason.INVALID_ARCHIVE, + "History CSV structure is invalid", + error); + } + + if (csvEntries == 0) { + throw new HistoryImportException( + HistoryImportException.Reason.INVALID_ARCHIVE, + "Archive contains no history CSV files"); + } + return result; + } + + private void readCsvEntry(final ZipInputStream zip, final ReadResult result) + throws IOException, HistoryImportException { + + // Closing a parser closes its Reader. The wrapper keeps that close from reaching the + // ZipInputStream, whose next entry still has to be read. + final InputStream currentEntry = new FilterInputStream(zip) { + @Override + public void close() { + // The owning readArchive try-with-resources closes the zip once, at the end. + } + }; + + try (InputStreamReader reader = new InputStreamReader( + currentEntry, StandardCharsets.UTF_8); + CSVParser parser = CSV.parse(reader)) { + + final Map headers = parser.getHeaderMap(); + if (!headers.containsKey(BEACON_ID)) { + throw new HistoryImportException( + HistoryImportException.Reason.UNSUPPORTED_LEGACY, + "History export has no stable beacon identity"); + } + if (!headers.keySet().containsAll(HistoryCsvWriter.requiredHeaders())) { + throw new HistoryImportException( + HistoryImportException.Reason.INVALID_ARCHIVE, + "History CSV is missing required columns"); + } + + for (CSVRecord record : parser) { + result.rowsRead++; + final HistoryImportRow row = parseRow(record); + if (row == null) { + result.malformedRows++; + } else { + result.rows.add(row); + } + } + } + } + + private static HistoryImportRow parseRow(final CSVRecord row) { + try { + final String beaconId = row.get(BEACON_ID); + if (beaconId == null || beaconId.isBlank()) { + return null; + } + + final double latitude = Double.parseDouble(row.get("latitude_exact")); + final double longitude = Double.parseDouble(row.get("longitude_exact")); + if (!Double.isFinite(latitude) || latitude < -90.0 || latitude > 90.0 + || !Double.isFinite(longitude) || longitude < -180.0 || longitude > 180.0) { + return null; + } + + final BeaconLocationReport report = BeaconLocationReport.builder() + // The two readable timestamp columns are deliberately informational. Epoch + // milliseconds are the lossless value the app wrote for restoration. + .timestamp(Long.parseLong(row.get("timestamp_epoch_ms"))) + .publishedAt(Long.parseLong(row.get("published_at_epoch_ms"))) + .latitude(latitude) + .longitude(longitude) + .horizontalAccuracy(Long.parseLong(row.get("horizontal_accuracy_m"))) + .confidence(Long.parseLong(row.get("confidence"))) + .status(Long.parseLong(row.get("status"))) + .description(parseDescription(row)) + .build(); + + return new HistoryImportRow(beaconId, report); + } catch (IllegalArgumentException error) { + return null; + } + } + + private static String parseDescription(final CSVRecord row) { + final String present = row.get("description_present"); + if ("false".equals(present)) { + return null; + } + if ("true".equals(present)) { + return row.get("description"); + } + throw new IllegalArgumentException("description_present is not a boolean"); + } + + private static final class ReadResult { + private final List rows = new ArrayList<>(); + private int rowsRead; + private int malformedRows; + } +} diff --git a/app/src/main/res/menu/my_devices_menu.xml b/app/src/main/res/menu/my_devices_menu.xml index 1a25efcb..e5046ceb 100644 --- a/app/src/main/res/menu/my_devices_menu.xml +++ b/app/src/main/res/menu/my_devices_menu.xml @@ -1,14 +1,14 @@

@@ -42,4 +42,8 @@ android:id="@+id/action_import_from_file" android:title="@string/icloud_import_from_file" /> + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eb846b7f..6e25788b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -313,4 +313,14 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Apple hat deinen Code angenommen und danach die Anmeldung nicht abschließen können. Der Code ist damit verbraucht, es wird also ein neuer gebraucht – wir warten kurz, bevor wir ihn anfordern, denn sofortiges Anfordern wird abgelehnt.
Neuer Code wird in %1$d s bei Apple angefordert … Apple schließt die Anmeldung weiterhin nicht ab. Das liegt an Apple, nicht an dir und nicht an deinem Code.\n\nVersuche es in ein paar Minuten erneut. Dein Passwort wird dabei möglicherweise einmal abgelehnt – das gehört zum selben Fehler, gib es also einfach noch einmal ein, statt es für falsch zu halten. + Verlauf importieren + Verlaufsimport abgeschlossen + Gelesene Zeilen: %1$d\nHinzugefügte Zeilen: %2$d\nBereits vorhanden: %3$d\nFehlerhaft: %4$d\nÜbersprungen (Tag nicht importiert): %5$d + Importiere zuerst die fehlenden Tags und anschließend diese Verlaufs-ZIP erneut, um die übersprungenen Zeilen wiederherzustellen. + Verlaufsexport ist zu alt + Dieser Export identifiziert Tags nicht zuverlässig, daher kann sein Verlauf nicht wiederhergestellt werden. Erstelle mit der aktuellen App eine neue Verlaufs-ZIP und importiere stattdessen diese Datei. + Verlaufsdatei nicht unterstützt + Wähle eine unbeschädigte ZIP-Datei, die mit „Verlauf exportieren“ in OpenTagViewer für Android erstellt wurde. + Verlauf konnte nicht importiert werden + Es wurde kein Verlauf importiert. Versuche es erneut oder wähle eine andere Verlaufs-ZIP. \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7672e7b4..38b09df3 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -313,4 +313,14 @@ You can set this up now, or any time later from Settings.
Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. + Import History + History import complete + Rows read: %1$d\nRows added: %2$d\nAlready present: %3$d\nMalformed: %4$d\nSkipped (tag not imported): %5$d + Import the missing tags first, then import this history ZIP again to restore the skipped rows. + History export is too old + This export does not identify tags safely, so its history cannot be restored. Create a new Export History ZIP with the current app and import that file instead. + History file not supported + Choose an undamaged ZIP created by Export History in OpenTagViewer for Android. + History could not be imported + No history was imported. Try again, or choose another Export History ZIP. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c09e863a..eef6b4b0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -313,4 +313,14 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Apple a accepté votre code puis n’a pas pu terminer la connexion. Le code est donc utilisé et il en faut un nouveau — nous patientons un instant avant de le demander, car une demande immédiate est refusée. Nouveau code demandé à Apple dans %1$d s… Apple ne termine toujours pas la connexion. C’est une panne du côté d’Apple, pas quelque chose que vous avez fait, ni votre code.\n\nRéessayez dans quelques minutes. Votre mot de passe pourra être refusé une fois à ce moment-là : cela fait partie de la même panne, saisissez-le à nouveau plutôt que de le croire erroné. + Importer l’historique + Importation de l’historique terminée + Lignes lues : %1$d\nLignes ajoutées : %2$d\nDéjà présentes : %3$d\nIncorrectes : %4$d\nIgnorées (balise non importée) : %5$d + Importez d’abord les balises manquantes, puis importez à nouveau ce fichier ZIP d’historique pour restaurer les lignes ignorées. + L’exportation de l’historique est trop ancienne + Cette exportation n’identifie pas les balises de manière fiable ; son historique ne peut donc pas être restauré. Créez un nouveau fichier ZIP d’historique avec l’application actuelle et importez-le à la place. + Fichier d’historique non pris en charge + Choisissez un fichier ZIP intact créé par « Exporter l’historique » dans OpenTagViewer pour Android. + Impossible d’importer l’historique + Aucun historique n’a été importé. Réessayez ou choisissez un autre fichier ZIP d’historique. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 652a6804..848af56e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -313,4 +313,14 @@ Apple はコードを受け付けたあと、サインインを完了できませんでした。コードは使用済みなので新しいものが必要です。すぐに要求しても拒否されるため、少し待ってから要求します。 %1$d 秒後に Apple へ新しいコードを要求します… Apple はまだサインインを完了できていません。これは Apple 側の障害であり、あなたの操作やコードのせいではありません。\n\n数分後にもう一度お試しください。そのときパスワードが一度だけ拒否されることがありますが、これも同じ障害の一部です。間違っていると考えず、もう一度入力してください。 + 履歴をインポート + 履歴のインポートが完了しました + 読み込んだ行: %1$d\n追加した行: %2$d\n既存の行: %3$d\n不正な行: %4$d\nスキップ (タグ未インポート): %5$d + 先に不足しているタグをインポートしてから、この履歴 ZIP をもう一度インポートすると、スキップされた行を復元できます。 + 履歴エクスポートが古すぎます + このエクスポートはタグを安全に識別できないため、履歴を復元できません。現在のアプリで新しい履歴 ZIP を作成し、そのファイルをインポートしてください。 + 履歴ファイルはサポートされていません + Android 版 OpenTagViewer の「履歴をエクスポート」で作成した、破損していない ZIP を選択してください。 + 履歴をインポートできませんでした + 履歴はインポートされませんでした。もう一度試すか、別の履歴 ZIP を選択してください。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ca9b23ba..3736ed96 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -313,4 +313,14 @@ Apple이 코드를 받은 뒤 로그인을 끝내지 못했습니다. 코드는 이미 사용되었으므로 새 코드가 필요합니다. 바로 요청하면 거부되기 때문에 잠시 기다린 뒤 요청합니다. %1$d초 후에 Apple에 새 코드를 요청합니다… Apple이 아직 로그인을 마치지 못하고 있습니다. 이는 Apple 쪽 장애이며, 사용자의 잘못도 코드 문제도 아닙니다.\n\n몇 분 뒤에 다시 시도하세요. 그때 비밀번호가 한 번 거부될 수 있는데, 이것도 같은 장애의 일부이므로 틀렸다고 생각하지 말고 다시 입력하세요. + 기록 가져오기 + 기록 가져오기 완료 + 읽은 행: %1$d\n추가된 행: %2$d\n이미 존재함: %3$d\n잘못된 행: %4$d\n건너뜀(태그를 가져오지 않음): %5$d + 누락된 태그를 먼저 가져온 다음 이 기록 ZIP을 다시 가져오면 건너뛴 행을 복원할 수 있습니다. + 기록 내보내기가 너무 오래됨 + 이 내보내기는 태그를 안전하게 식별하지 못하므로 기록을 복원할 수 없습니다. 현재 앱에서 새 기록 ZIP을 만든 후 해당 파일을 가져오세요. + 지원되지 않는 기록 파일 + Android용 OpenTagViewer의 기록 내보내기에서 만든 손상되지 않은 ZIP을 선택하세요. + 기록을 가져올 수 없음 + 기록을 가져오지 않았습니다. 다시 시도하거나 다른 기록 ZIP을 선택하세요. \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3139a056..9e79295b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -313,4 +313,14 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Apple heeft je code geaccepteerd en kon daarna het inloggen niet afronden. De code is dus opgebruikt en er is een nieuwe nodig — we wachten even voordat we die aanvragen, want meteen aanvragen wordt geweigerd. Over %1$d s wordt een nieuwe code bij Apple aangevraagd… Apple rondt het inloggen nog steeds niet af. Dit ligt aan Apple, niet aan jou en niet aan je code.\n\nProbeer het over een paar minuten opnieuw. Je wachtwoord kan dan één keer worden geweigerd — dat hoort bij dezelfde storing, dus voer het gewoon nog een keer in in plaats van aan te nemen dat het fout is. + Geschiedenis importeren + Geschiedenis geïmporteerd + Gelezen rijen: %1$d\nToegevoegde rijen: %2$d\nAl aanwezig: %3$d\nOngeldig: %4$d\nOvergeslagen (tag niet geïmporteerd): %5$d + Importeer eerst de ontbrekende tags en importeer deze geschiedenis-ZIP daarna opnieuw om de overgeslagen rijen te herstellen. + Geschiedenisexport is te oud + Deze export identificeert tags niet veilig, waardoor de geschiedenis niet kan worden hersteld. Maak met de huidige app een nieuwe geschiedenis-ZIP en importeer die. + Geschiedenisbestand niet ondersteund + Kies een onbeschadigde ZIP die is gemaakt met Geschiedenis exporteren in OpenTagViewer voor Android. + Geschiedenis kon niet worden geïmporteerd + Er is geen geschiedenis geïmporteerd. Probeer het opnieuw of kies een andere geschiedenis-ZIP. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6b0fd930..a4f91248 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -313,4 +313,14 @@ Apple приняла ваш код, а затем не смогла завершить вход. Код уже использован, поэтому нужен новый — подождём немного перед запросом, потому что сразу запрашивать бесполезно. Запросим новый код у Apple через %1$d с… Apple по-прежнему не завершает вход. Это сбой на стороне Apple — не ваша вина и не проблема кода.\n\nПопробуйте снова через несколько минут. Пароль при этом может быть отклонён один раз: это часть того же сбоя, поэтому введите его ещё раз, а не считайте неверным. + Импортировать историю + Импорт истории завершён + Прочитано строк: %1$d\nДобавлено строк: %2$d\nУже есть: %3$d\nПовреждено: %4$d\nПропущено (метка не импортирована): %5$d + Сначала импортируйте недостающие метки, а затем снова импортируйте этот ZIP-файл истории, чтобы восстановить пропущенные строки. + Экспорт истории слишком старый + Этот экспорт не позволяет надёжно определить метки, поэтому историю нельзя восстановить. Создайте новый ZIP-файл истории в текущей версии приложения и импортируйте его. + Файл истории не поддерживается + Выберите неповреждённый ZIP-файл, созданный командой «Экспорт истории» в OpenTagViewer для Android. + Не удалось импортировать историю + История не была импортирована. Повторите попытку или выберите другой ZIP-файл истории. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1a85cf5e..051ba5be 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -313,4 +313,14 @@ Apple 已接受你的验证码,随后未能完成登录。该验证码已被用掉,需要一个新的——我们会先等一会儿再申请,因为立刻申请会被拒绝。 将在 %1$d 秒后向 Apple 申请新验证码… Apple 仍未完成登录。这是 Apple 一侧的故障,不是你的操作问题,也不是验证码的问题。\n\n请过几分钟再试。届时你的密码可能会被拒绝一次——这属于同一个故障,请再输入一次,而不要以为密码错了。 + 导入历史记录 + 历史记录导入完成 + 读取的行数:%1$d\n新增的行数:%2$d\n已存在:%3$d\n格式错误:%4$d\n已跳过(标签未导入):%5$d + 请先导入缺失的标签,然后再次导入此历史记录 ZIP,以恢复已跳过的行。 + 历史记录导出文件版本过旧 + 此导出文件无法安全识别标签,因此不能恢复其中的历史记录。请使用当前应用创建新的历史记录 ZIP,然后导入新文件。 + 不支持此历史记录文件 + 请选择由 Android 版 OpenTagViewer 的“导出历史记录”创建且未损坏的 ZIP。 + 无法导入历史记录 + 未导入任何历史记录。请重试或选择其他历史记录 ZIP。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a0792b1d..45b79587 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -313,4 +313,14 @@ Apple 已接受你的驗證碼,隨後未能完成登入。該驗證碼已被用掉,需要一個新的——我們會先等一下再申請,因為立刻申請會被拒絕。 將在 %1$d 秒後向 Apple 申請新驗證碼… Apple 仍未完成登入。這是 Apple 一側的故障,不是你的操作問題,也不是驗證碼的問題。\n\n請過幾分鐘再試。屆時你的密碼可能會被拒絕一次——這屬於同一個故障,請再輸入一次,而不要以為密碼錯了。 + 匯入歷史記錄 + 歷史記錄匯入完成 + 讀取的列數:%1$d\n新增的列數:%2$d\n已存在:%3$d\n格式錯誤:%4$d\n已略過(標籤未匯入):%5$d + 請先匯入缺少的標籤,然後再次匯入此歷史記錄 ZIP,以還原已略過的列。 + 歷史記錄匯出檔版本過舊 + 此匯出檔無法安全識別標籤,因此不能還原其中的歷史記錄。請使用目前的應用程式建立新的歷史記錄 ZIP,然後匯入新檔案。 + 不支援此歷史記錄檔 + 請選擇由 Android 版 OpenTagViewer 的「匯出歷史記錄」建立且未損壞的 ZIP。 + 無法匯入歷史記錄 + 未匯入任何歷史記錄。請重試或選擇其他歷史記錄 ZIP。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ce454d4..9db173d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -345,4 +345,14 @@ You can set this up now, or any time later from Settings. Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. + Import History + History import complete + Rows read: %1$d\nRows added: %2$d\nAlready present: %3$d\nMalformed: %4$d\nSkipped (tag not imported): %5$d + Import the missing tags first, then import this history ZIP again to restore the skipped rows. + History export is too old + This export does not identify tags safely, so its history cannot be restored. Create a new Export History ZIP with the current app and import that file instead. + History file not supported + Choose an undamaged ZIP created by Export History in OpenTagViewer for Android. + History could not be imported + No history was imported. Try again, or choose another Export History ZIP. diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java index 6bfbbce3..c726fd32 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java @@ -25,6 +25,7 @@ public class HistoryCsvWriterTest { private static final ZoneId AMSTERDAM = ZoneId.of("Europe/Amsterdam"); + private static final String BEACON_ID = "beacon-123"; /** * Summer, so Amsterdam is +02:00 and the UTC and local columns differ visibly. @@ -50,10 +51,20 @@ private static BeaconLocationReport.BeaconLocationReportBuilder report() { private static String write(final List reports) throws IOException { StringWriter out = new StringWriter(); - new HistoryCsvWriter(AMSTERDAM).write(out, reports); + new HistoryCsvWriter(AMSTERDAM).write(out, BEACON_ID, reports); return out.toString(); } + @Test + public void everyReportCarriesTheStableBeaconIdentity() throws Exception { + final String[] rows = write(List.of(report().build())).split("\r\n"); + + assertTrue("the header should identify the stable beacon column", + rows[0].endsWith(",beacon_id")); + assertTrue("the report should carry the stable beacon id, got: " + rows[1], + rows[1].endsWith("," + BEACON_ID)); + } + @Test public void anEmptyHistoryStillWritesItsColumnNames() throws Exception { // A tag with nothing recorded is a normal state, not a failure. An empty file would diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriterTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriterTest.java index b6917786..3099401a 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriterTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryZipWriterTest.java @@ -12,9 +12,7 @@ import java.time.Instant; import java.time.ZoneId; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -52,7 +50,7 @@ private static List entryNamesOf(final byte[] zipBytes) throws IOExcepti return names; } - private static byte[] write(final Map> history) + private static byte[] write(final List history) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); new HistoryZipWriter(UTC).write(out, history); @@ -61,9 +59,9 @@ private static byte[] write(final Map> histor @Test public void entryNamesCarryTheTagAndTheRangeItCovers() throws Exception { - Map> history = new LinkedHashMap<>(); - history.put("Shane's Wallet", List.of( - at("2026-07-02T08:00:00Z"), at("2026-08-15T20:00:00Z"))); + List history = List.of(new HistoryExportEntry( + "wallet-id", "Shane's Wallet", List.of( + at("2026-07-02T08:00:00Z"), at("2026-08-15T20:00:00Z")))); assertEquals(List.of("Shane's Wallet_2026-07-02_2026-08-15.csv"), entryNamesOf(write(history))); @@ -73,9 +71,11 @@ public void entryNamesCarryTheTagAndTheRangeItCovers() throws Exception { public void twoTagsSharingANameDoNotProduceADuplicateEntry() throws Exception { // Nothing stops somebody naming two tags "Keys", and some readers reject an archive // outright when it contains the same entry twice. - Map> history = new LinkedHashMap<>(); - history.put("Keys", List.of(at("2026-08-15T10:00:00Z"))); - history.put("Keys ", List.of(at("2026-08-15T10:00:00Z"))); + List history = List.of( + new HistoryExportEntry("keys-1", "Keys", + List.of(at("2026-08-15T10:00:00Z"))), + new HistoryExportEntry("keys-2", "Keys", + List.of(at("2026-08-15T10:00:00Z")))); List names = entryNamesOf(write(history)); @@ -98,8 +98,8 @@ public void charactersFilesystemsRejectAreStrippedFromEntryNames() { public void aTagWithNoHistoryStillGetsAFileWithItsColumns() throws Exception { // A tag with no locations is a normal state - see the beacons that get no card - and // silently omitting it would read as the export having lost something. - Map> history = new LinkedHashMap<>(); - history.put("Never Seen", List.of()); + List history = List.of( + new HistoryExportEntry("never-seen", "Never Seen", List.of())); byte[] zipBytes = write(history); assertEquals(List.of("Never Seen.csv"), entryNamesOf(zipBytes)); @@ -114,10 +114,13 @@ public void aTagWithNoHistoryStillGetsAFileWithItsColumns() throws Exception { @Test public void everySelectedTagGetsItsOwnFile() throws Exception { - Map> history = new LinkedHashMap<>(); - history.put("Wallet", List.of(at("2026-08-15T10:00:00Z"))); - history.put("Backpack", List.of(at("2026-08-14T10:00:00Z"))); - history.put("Keys", List.of(at("2026-08-13T10:00:00Z"))); + List history = List.of( + new HistoryExportEntry("wallet", "Wallet", + List.of(at("2026-08-15T10:00:00Z"))), + new HistoryExportEntry("backpack", "Backpack", + List.of(at("2026-08-14T10:00:00Z"))), + new HistoryExportEntry("keys", "Keys", + List.of(at("2026-08-13T10:00:00Z")))); assertEquals(3, entryNamesOf(write(history)).size()); } diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java new file mode 100644 index 00000000..dad1ba9d --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java @@ -0,0 +1,244 @@ +package dev.wander.android.opentagviewer.util.history; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.util.export.HistoryExportEntry; +import dev.wander.android.opentagviewer.util.export.HistoryCsvWriter; +import dev.wander.android.opentagviewer.util.export.HistoryZipWriter; + +/** The history backup crossing its public restore seam. */ +public class HistoryImporterTest { + + private static final String BEACON_ID = "beacon-123"; + private static final long RECORDED_AT = + Instant.parse("2026-08-15T12:34:56Z").toEpochMilli(); + + @Test + public void whatTheAppExportsCanBeReadBackWithoutLosingAReportField() throws Exception { + final BeaconLocationReport report = BeaconLocationReport.builder() + .timestamp(RECORDED_AT) + .publishedAt(RECORDED_AT + 60_001L) + .latitude(52.37021571234567) + .longitude(4.895167912345678) + .horizontalAccuracy(12) + .confidence(2) + .status(1) + .description("Hiraistraat 9D, \"Amsterdam\"\r\nsecond line") + .build(); + final ByteArrayOutputStream archive = new ByteArrayOutputStream(); + new HistoryZipWriter(ZoneId.of("Europe/Amsterdam")).write( + archive, + List.of(new HistoryExportEntry(BEACON_ID, "Wallet", List.of(report)))); + + final List received = new ArrayList<>(); + final HistoryImporter importer = new HistoryImporter( + (rows, rowsRead, malformedRows, now) -> { + received.addAll(rows); + return new HistoryImportResult(rowsRead, rows.size(), 0, malformedRows, 0); + }, + () -> 1_800_000_000_000L); + + final HistoryImportResult result = importer.importArchive( + new ByteArrayInputStream(archive.toByteArray())); + + assertEquals(1, result.getRowsRead()); + assertEquals(1, result.getRowsAdded()); + assertEquals(1, received.size()); + assertEquals(BEACON_ID, received.get(0).getBeaconId()); + assertEquals(report, received.get(0).getReport()); + } + + @Test + public void anAbsentDescriptionStaysAbsentRatherThanBecomingEmptyText() throws Exception { + final BeaconLocationReport report = BeaconLocationReport.builder() + .timestamp(RECORDED_AT) + .publishedAt(RECORDED_AT + 1L) + .latitude(1.123456789012345) + .longitude(2.123456789012345) + .horizontalAccuracy(12) + .confidence(2) + .status(1) + .description(null) + .build(); + final ByteArrayOutputStream archive = new ByteArrayOutputStream(); + new HistoryZipWriter(ZoneId.of("UTC")).write( + archive, + List.of(new HistoryExportEntry(BEACON_ID, "Wallet", List.of(report)))); + final List received = new ArrayList<>(); + + importerCapturing(received).importArchive( + new ByteArrayInputStream(archive.toByteArray())); + + assertEquals(report, received.get(0).getReport()); + } + + @Test + public void aMalformedRowIsCountedWithoutBlockingTheRowsAroundIt() throws Exception { + final String header = String.join(",", HistoryCsvWriter.requiredHeaders()); + final String bad = row("not-a-latitude", "bad"); + final String good = row("52.3702157", "good"); + final List received = new ArrayList<>(); + final HistoryImporter importer = importerCapturing(received); + + final HistoryImportResult result = importer.importArchive(new ByteArrayInputStream( + zip("Wallet.csv", header + "\r\n" + bad + "\r\n" + good + "\r\n"))); + + assertEquals(2, result.getRowsRead()); + assertEquals(1, result.getRowsAdded()); + assertEquals(1, result.getRowsMalformed()); + assertEquals("good", received.get(0).getReport().getDescription()); + } + + @Test + public void headerOrderAndExtraColumnsDoNotChangeTheContract() throws Exception { + final String csv = "beacon_id,description,status,confidence,horizontal_accuracy_m," + + "description_present,longitude,longitude_exact,latitude,latitude_exact," + + "published_at_utc,published_at_epoch_ms,timestamp_epoch_ms,timestamp_local," + + "timestamp_utc,future_column\r\n" + + BEACON_ID + ",somewhere,1,2,12,true,4.8951679,4.895167912345678," + + "52.3702157,52.37021571234567,2026-08-15T12:35:56Z," + + (RECORDED_AT + 60_000L) + "," + RECORDED_AT + "," + + "2026-08-15 14:34:56+02:00,2026-08-15T12:34:56Z,ignored\r\n"; + + final HistoryImportResult result = importerCapturing(new ArrayList<>()).importArchive( + new ByteArrayInputStream(zip("Wallet.csv", csv))); + + assertEquals(1, result.getRowsAdded()); + } + + @Test + public void aNameOnlyExportIsRejectedInsteadOfGuessedFromItsFilename() throws Exception { + final List currentHeaders = HistoryCsvWriter.requiredHeaders(); + final String legacyHeader = String.join(",", + currentHeaders.subList(0, currentHeaders.size() - 1)); + + final HistoryImportException error = assertThrows( + HistoryImportException.class, + () -> importerCapturing(new ArrayList<>()).importArchive( + new ByteArrayInputStream(zip("Wallet.csv", legacyHeader + "\r\n")))); + + assertEquals(HistoryImportException.Reason.UNSUPPORTED_LEGACY, error.getReason()); + } + + @Test + public void damagedZipAndAnArchiveWithNoCsvAreWholeArchiveFailures() throws Exception { + final HistoryImporter importer = importerCapturing(new ArrayList<>()); + + final HistoryImportException notAZip = assertThrows( + HistoryImportException.class, + () -> importer.importArchive(new ByteArrayInputStream( + "not a zip".getBytes(StandardCharsets.UTF_8)))); + final HistoryImportException noCsv = assertThrows( + HistoryImportException.class, + () -> importer.importArchive(new ByteArrayInputStream( + zip("readme.txt", "nothing to import")))); + + assertEquals(HistoryImportException.Reason.INVALID_ARCHIVE, notAZip.getReason()); + assertEquals(HistoryImportException.Reason.INVALID_ARCHIVE, noCsv.getReason()); + } + + @Test + public void malformedCsvSyntaxIsAWholeArchiveFailure() throws Exception { + final String header = String.join(",", HistoryCsvWriter.requiredHeaders()); + final String unclosedQuotedField = header + "\r\n\"unterminated"; + + final HistoryImportException error = assertThrows( + HistoryImportException.class, + () -> importerCapturing(new ArrayList<>()).importArchive( + new ByteArrayInputStream(zip("Wallet.csv", unclosedQuotedField)))); + + assertEquals(HistoryImportException.Reason.INVALID_ARCHIVE, error.getReason()); + } + + @Test + public void aLaterBrokenFilePreventsTheEntireArchiveReachingPersistence() throws Exception { + final AtomicBoolean persistenceWasCalled = new AtomicBoolean(false); + final HistoryImporter importer = new HistoryImporter( + (rows, rowsRead, malformedRows, now) -> { + persistenceWasCalled.set(true); + return new HistoryImportResult(rowsRead, rows.size(), 0, malformedRows, 0); + }, + () -> 1_800_000_000_000L); + final List currentHeaders = HistoryCsvWriter.requiredHeaders(); + final String current = String.join(",", currentHeaders) + + "\r\n" + row("52.3702157", "valid") + "\r\n"; + final String legacy = String.join(",", + currentHeaders.subList(0, currentHeaders.size() - 1)) + "\r\n"; + + final HistoryImportException error = assertThrows( + HistoryImportException.class, + () -> importer.importArchive(new ByteArrayInputStream( + zip(List.of("valid.csv", "legacy.csv"), List.of(current, legacy))))); + + assertEquals(HistoryImportException.Reason.UNSUPPORTED_LEGACY, error.getReason()); + assertFalse("no file should be merged before the whole archive validates", + persistenceWasCalled.get()); + } + + @Test + public void databaseFailureHasItsOwnTypedResult() throws Exception { + final HistoryImporter importer = new HistoryImporter( + (rows, rowsRead, malformedRows, now) -> { + throw new IllegalStateException("database unavailable"); + }, + () -> 1_800_000_000_000L); + + final HistoryImportException error = assertThrows( + HistoryImportException.class, + () -> importer.importArchive(new ByteArrayInputStream(zip( + "Wallet.csv", String.join(",", + HistoryCsvWriter.requiredHeaders()) + "\r\n")))); + + assertEquals(HistoryImportException.Reason.DATABASE_FAILED, error.getReason()); + } + + private static HistoryImporter importerCapturing(final List received) { + return new HistoryImporter( + (rows, rowsRead, malformedRows, now) -> { + received.addAll(rows); + return new HistoryImportResult(rowsRead, rows.size(), 0, malformedRows, 0); + }, + () -> 1_800_000_000_000L); + } + + private static String row(final String latitude, final String description) { + return "2026-08-15T12:34:56Z,2026-08-15 14:34:56+02:00," + + RECORDED_AT + "," + latitude + ",4.8951679,12,2,1," + + "2026-08-15T12:35:56Z," + description + "," + + (RECORDED_AT + 60_000L) + "," + latitude + ",4.8951679,true," + + BEACON_ID; + } + + private static byte[] zip(final String name, final String contents) throws Exception { + return zip(List.of(name), List.of(contents)); + } + + private static byte[] zip(final List names, final List contents) + throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + for (int index = 0; index < names.size(); index++) { + zip.putNextEntry(new ZipEntry(names.get(index))); + zip.write(contents.get(index).getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return out.toByteArray(); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3170f5ff..9131bb20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,8 @@ amapVersion = "9.8.3" # Reads the AES-256 (WinZip) encryption the exporter locks bundles with. java.util.zip cannot # decrypt anything at all - not AES, not the legacy ZipCrypto. zip4j = "2.11.5" +# Reads RFC 4180 history exports, including quoted descriptions with embedded newlines. +commonsCsv = "1.14.1" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -87,6 +89,7 @@ androidx-emoji-views-helper = { group = "androidx.emoji2", name = "emoji2-views- androidx-emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" } amap-map3d = { group = "com.amap.api", name = "3dmap", version.ref = "amapVersion" } zip4j = { group = "net.lingala.zip4j", name = "zip4j", version.ref = "zip4j" } +commons-csv = { group = "org.apache.commons", name = "commons-csv", version.ref = "commonsCsv" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 402e95db77875017167bc2599722b9d504b9c84b Mon Sep 17 00:00:00 2001 From: hamza-lzr Date: Wed, 2 Sep 2026 18:27:06 +0100 Subject: [PATCH 2/4] Address history import review findings --- .../ui/SpinnersLookLikeSpinnersTest.java | 2 +- .../HistoryImportProgressLayoutTest.java | 58 +++++++++ ...ImportingHistoryFromTheDeviceListTest.java | 112 ++++++++++++++++++ .../opentagviewer/MyDevicesListActivity.java | 96 +++++++++++++-- .../db/room/dao/HistoryImportDao.java | 20 +++- .../opentagviewer/python/AppDependencies.java | 41 +++++-- .../util/history/HistoryArchiveImporter.java | 13 ++ .../util/history/HistoryImportException.java | 1 + .../util/history/HistoryImportProgress.java | 15 +++ .../util/history/HistoryImportSink.java | 3 +- .../util/history/HistoryImporter.java | 35 ++++-- .../res/layout/history_import_progress.xml | 20 ++++ app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../util/history/HistoryImporterTest.java | 42 ++++++- 23 files changed, 452 insertions(+), 36 deletions(-) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryArchiveImporter.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportProgress.java create mode 100644 app/src/main/res/layout/history_import_progress.xml diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/SpinnersLookLikeSpinnersTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/SpinnersLookLikeSpinnersTest.java index 32d5be16..0b097852 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/SpinnersLookLikeSpinnersTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/SpinnersLookLikeSpinnersTest.java @@ -46,7 +46,7 @@ public class SpinnersLookLikeSpinnersTest { /** What the app has today. A new spinner should raise this, not be excluded from it. */ - private static final int AT_LEAST_THIS_MANY = 8; + private static final int AT_LEAST_THIS_MANY = 9; private static Context themed() { return new ContextThemeWrapper( diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java new file mode 100644 index 00000000..4c85cd4a --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java @@ -0,0 +1,58 @@ +package dev.wander.android.opentagviewer.ui.mydevices; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.google.android.material.progressindicator.CircularProgressIndicator; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.R; + +/** Cheap inflation coverage for the custom view placed inside the import dialog. */ +@RunWith(AndroidJUnit4.class) +public class HistoryImportProgressLayoutTest { + + @Test + public void itInflatesAndMeasuresInBothThemes() { + for (final boolean night : new boolean[]{false, true}) { + final View root = inflate(night); + final CircularProgressIndicator indicator = + root.findViewById(R.id.history_import_progress); + + assertNotNull(indicator); + assertTrue(indicator.isIndeterminate()); + + root.measure( + View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + root.layout(0, 0, root.getMeasuredWidth(), root.getMeasuredHeight()); + + assertTrue("the progress dialog content measured to nothing", + root.getMeasuredHeight() > 0); + assertTrue("the progress indicator measured to nothing", + indicator.getMeasuredWidth() > 0 && indicator.getMeasuredHeight() > 0); + } + } + + private static View inflate(final boolean night) { + final Context base = getInstrumentation().getTargetContext(); + final Configuration configuration = new Configuration( + base.getResources().getConfiguration()); + configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) + | (night ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO); + final Context themed = new ContextThemeWrapper( + base.createConfigurationContext(configuration), R.style.Theme_OpenTagViewer); + return LayoutInflater.from(themed).inflate(R.layout.history_import_progress, null); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java index 30b8d09a..cefb406c 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java @@ -6,7 +6,11 @@ import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.Intents.intended; +import static androidx.test.espresso.intent.VerificationModes.times; import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra; import static androidx.test.espresso.matcher.RootMatchers.isDialog; import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; @@ -32,6 +36,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; +import com.google.android.material.progressindicator.CircularProgressIndicator; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -43,6 +49,8 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -53,7 +61,12 @@ import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; import dev.wander.android.opentagviewer.db.room.entity.Import; import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; import dev.wander.android.opentagviewer.util.export.HistoryCsvWriter; +import dev.wander.android.opentagviewer.util.history.HistoryImportException; +import dev.wander.android.opentagviewer.util.history.HistoryImportProgress; +import dev.wander.android.opentagviewer.util.history.HistoryImportResult; /** The history picker and every result the My Devices screen promises to explain. */ @LargeTest @@ -81,6 +94,8 @@ public class ImportingHistoryFromTheDeviceListTest { private final List files = new ArrayList<>(); private OpenTagViewerDatabase db; private ActivityScenario scenario; + private CountDownLatch showFakeMerge; + private CountDownLatch finishFakeImport; @Before public void seedOneKnownTag() { @@ -112,10 +127,17 @@ public void seedOneKnownTag() { @After public void cleanUp() { + if (this.showFakeMerge != null) { + this.showFakeMerge.countDown(); + } + if (this.finishFakeImport != null) { + this.finishFakeImport.countDown(); + } if (this.scenario != null) { this.scenario.close(); } Intents.release(); + AppDependencies.reset(); this.forgetTestData(); for (File file : this.files) { file.delete(); @@ -138,6 +160,7 @@ public void validArchiveReportsEveryCountRefreshesTheRowAndTellsTheMap() throws R.string.history_import_result_counts, 4, 1, 1, 1, 1); Eventually.check(() -> onView(withText(containsString(counts))).inRoot(isDialog()) .check(matches(isDisplayed()))); + onView(withId(R.id.history_import_progress)).check(doesNotExist()); onView(withText(containsString(context.getString( R.string.history_import_unknown_guidance)))) .inRoot(isDialog()) @@ -155,6 +178,70 @@ public void validArchiveReportsEveryCountRefreshesTheRowAndTellsTheMap() throws .getBooleanExtra("isDeviceListChanged", false)); } + @Test + public void aLongImportShowsAndUpdatesProgressBeforeItsResult() throws Exception { + final CountDownLatch startedReading = new CountDownLatch(1); + final CountDownLatch reachedMerge = new CountDownLatch(1); + this.showFakeMerge = new CountDownLatch(1); + this.finishFakeImport = new CountDownLatch(1); + AppDependencies.replaceHistoryImporter((archive, progress) -> { + progress.changed(HistoryImportProgress.Stage.READING, 0, 0); + startedReading.countDown(); + awaitTestLatch(this.showFakeMerge); + progress.changed(HistoryImportProgress.Stage.MERGING, 4, 10); + reachedMerge.countDown(); + awaitTestLatch(this.finishFakeImport); + return new HistoryImportResult(10, 0, 10, 0, 0); + }); + this.answerPickerWith(zip("ignored.txt", "the fake importer owns this")); + this.openAndChooseHistory(); + + assertTrue(startedReading.await(5, TimeUnit.SECONDS)); + Eventually.check(() -> onView(withId(R.id.history_import_progress)) + .inRoot(isDialog()).check((view, missing) -> { + if (missing != null) { + throw missing; + } + assertTrue(((CircularProgressIndicator) view).isIndeterminate()); + })); + + this.showFakeMerge.countDown(); + assertTrue(reachedMerge.await(5, TimeUnit.SECONDS)); + Eventually.check(() -> onView(withId(R.id.history_import_progress)) + .inRoot(isDialog()).check((view, missing) -> { + if (missing != null) { + throw missing; + } + final CircularProgressIndicator indicator = + (CircularProgressIndicator) view; + assertFalse(indicator.isIndeterminate()); + assertEquals(10, indicator.getMax()); + assertEquals(4, indicator.getProgress()); + })); + + this.finishFakeImport.countDown(); + Eventually.check(() -> onView(withText(R.string.history_import_complete_title)) + .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withId(R.id.history_import_progress)).check(doesNotExist()); + } + + private static void awaitTestLatch(final CountDownLatch latch) + throws HistoryImportException { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new HistoryImportException( + HistoryImportException.Reason.UNEXPECTED, + "test import was never released"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new HistoryImportException( + HistoryImportException.Reason.UNEXPECTED, + "test import was interrupted", + error); + } + } + @Test public void legacyNameOnlyExportExplainsWhyItIsUnsafe() throws Exception { final List currentHeaders = HistoryCsvWriter.requiredHeaders(); @@ -165,6 +252,7 @@ public void legacyNameOnlyExportExplainsWhyItIsUnsafe() throws Exception { Eventually.check(() -> onView(withText(R.string.history_import_legacy_title)) .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withId(R.id.history_import_progress)).check(doesNotExist()); onView(withText(R.string.history_import_legacy_message)).inRoot(isDialog()) .check(matches(isDisplayed())); } @@ -176,8 +264,10 @@ public void damagedOrUnsupportedArchiveGetsItsOwnExplanation() throws Exception Eventually.check(() -> onView(withText(R.string.history_import_invalid_title)) .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withId(R.id.history_import_progress)).check(doesNotExist()); onView(withText(R.string.history_import_invalid_message)).inRoot(isDialog()) .check(matches(isDisplayed())); + intended(hasComponent(ErrorReportActivity.class.getName()), times(0)); } @Test @@ -191,10 +281,32 @@ public void readFailureUsesTheGenericFailureMessage() { Eventually.check(() -> onView(withText(R.string.history_import_failed_title)) .inRoot(isDialog()).check(matches(isDisplayed()))); + onView(withId(R.id.history_import_progress)).check(doesNotExist()); onView(withText(R.string.history_import_failed_message)).inRoot(isDialog()) .check(matches(isDisplayed())); } + @Test + public void anUnexpectedFailureReachesTheBugPageWithItsRootCause() throws Exception { + AppDependencies.replaceHistoryImporter((archive, progress) -> { + throw new RuntimeException( + "history wrapper", + new IllegalStateException("parser invariant failed")); + }); + intending(hasComponent(ErrorReportActivity.class.getName())) + .respondWith(new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)); + this.answerPickerWith(zip("ignored.txt", "the fake importer owns this")); + this.openAndChooseHistory(); + + Eventually.check(() -> intended(allOf( + hasComponent(ErrorReportActivity.class.getName()), + hasExtra(ErrorReportActivity.EXTRA_BODY, + R.string.error_report_body_history_import), + hasExtra(ErrorReportActivity.EXTRA_CAUSE, + "IllegalStateException: parser invariant failed")))); + onView(withId(R.id.history_import_progress)).check(doesNotExist()); + } + @Test public void cancellingThePickerChangesNeitherHistoryNorTheMapResult() { intending(hasAction(Intent.ACTION_OPEN_DOCUMENT)) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index ff7ddbe2..2170dc75 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -14,6 +14,7 @@ import android.widget.Toast; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.progressindicator.CircularProgressIndicator; import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResult; @@ -49,6 +50,7 @@ import dev.wander.android.opentagviewer.ui.login.SignInAgain; import dev.wander.android.opentagviewer.python.icloud.ICloudFailures; import dev.wander.android.opentagviewer.python.icloud.AccountRefresher; +import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.db.repo.UserSettingsRepository; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.util.TagOrder; @@ -67,7 +69,7 @@ import dev.wander.android.opentagviewer.util.export.HistoryZipWriter; import dev.wander.android.opentagviewer.util.history.HistoryImportException; import dev.wander.android.opentagviewer.util.history.HistoryImportResult; -import dev.wander.android.opentagviewer.util.history.HistoryImporter; +import dev.wander.android.opentagviewer.util.history.HistoryImportProgress; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; @@ -115,6 +117,10 @@ public class MyDevicesListActivity extends AppCompatActivity { /** The in-flight read of that, so leaving does not land on a menu that has gone. */ private Disposable membershipLookup; + /** The visible owner of a history import while its blocking work runs on the IO scheduler. */ + private AlertDialog historyImportProgressDialog; + private CircularProgressIndicator historyImportProgressIndicator; + /** * The tags whose history is being written, captured when the storage picker was opened. * @@ -285,6 +291,7 @@ protected void onDestroy() { if (this.membershipLookup != null && !this.membershipLookup.isDisposed()) { this.membershipLookup.dispose(); } + this.dismissHistoryImportProgress(); super.onDestroy(); } @@ -663,13 +670,14 @@ private void showPageMenu() { } private void importHistory(@NonNull final Uri uri) { + this.showHistoryImportProgress(); Observable.fromCallable(() -> { final InputStream opened = this.getContentResolver().openInputStream(uri); if (opened == null) { throw new IOException("The document provider returned no history data"); } - return new HistoryImporter(OpenTagViewerDatabase.getInstance( - this.getApplicationContext())).importArchive(opened); + return AppDependencies.historyImporter(this.getApplicationContext()) + .importArchive(opened, this::historyImportProgressChanged); }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -677,6 +685,10 @@ private void importHistory(@NonNull final Uri uri) { } private void historyImported(@NonNull final HistoryImportResult result) { + this.dismissHistoryImportProgress(); + if (this.isFinishing() || this.isDestroyed()) { + return; + } if (result.getRowsAdded() > 0) { this.devicesListChanged = true; this.refreshLatestLocations(); @@ -706,16 +718,29 @@ private void historyImported(@NonNull final HistoryImportResult result) { private void historyImportFailed(@NonNull final Throwable error) { Log.e(TAG, "Could not import history", error); + this.dismissHistoryImportProgress(); + if (this.isFinishing() || this.isDestroyed()) { + return; + } + + final HistoryImportException failure = historyImportExceptionIn(error); + if (failure == null + || failure.getReason() == HistoryImportException.Reason.UNEXPECTED) { + this.startActivity(ErrorReportActivity.intentFor( + this, + ErrorReportActivity.describe(rootOf(error)), + R.string.error_report_body_history_import)); + return; + } + final int title; final int message; - if (error instanceof HistoryImportException - && ((HistoryImportException) error).getReason() - == HistoryImportException.Reason.UNSUPPORTED_LEGACY) { + if (failure != null + && failure.getReason() == HistoryImportException.Reason.UNSUPPORTED_LEGACY) { title = R.string.history_import_legacy_title; message = R.string.history_import_legacy_message; - } else if (error instanceof HistoryImportException - && ((HistoryImportException) error).getReason() - == HistoryImportException.Reason.INVALID_ARCHIVE) { + } else if (failure != null + && failure.getReason() == HistoryImportException.Reason.INVALID_ARCHIVE) { title = R.string.history_import_invalid_title; message = R.string.history_import_invalid_message; } else { @@ -733,6 +758,59 @@ private void historyImportFailed(@NonNull final Throwable error) { .show(); } + private void showHistoryImportProgress() { + final View view = this.getLayoutInflater().inflate( + R.layout.history_import_progress, null); + this.historyImportProgressIndicator = view.findViewById(R.id.history_import_progress); + this.historyImportProgressDialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.import_history) + .setView(view) + .setCancelable(false) + .create(); + this.historyImportProgressDialog.setCanceledOnTouchOutside(false); + this.historyImportProgressDialog.show(); + } + + private void historyImportProgressChanged( + final HistoryImportProgress.Stage stage, + final int completed, + final int total) { + this.runOnUiThread(() -> { + final CircularProgressIndicator indicator = this.historyImportProgressIndicator; + if (indicator == null || this.isFinishing() || this.isDestroyed()) { + return; + } + if (stage == HistoryImportProgress.Stage.READING) { + indicator.setIndeterminate(true); + return; + } + + indicator.setIndeterminate(false); + indicator.setMax(Math.max(1, total)); + indicator.setProgressCompat(completed, true); + }); + } + + private void dismissHistoryImportProgress() { + if (this.historyImportProgressDialog != null) { + this.historyImportProgressDialog.dismiss(); + this.historyImportProgressDialog = null; + } + this.historyImportProgressIndicator = null; + } + + private static HistoryImportException historyImportExceptionIn(final Throwable error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (cause instanceof HistoryImportException) { + return (HistoryImportException) cause; + } + if (cause == cause.getCause()) { + break; + } + } + return null; + } + /** * Read the account now, because somebody asked. * diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java index e56540f9..20cf0eee 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java @@ -16,6 +16,7 @@ import dev.wander.android.opentagviewer.util.BeaconLocationReportHasher; import dev.wander.android.opentagviewer.util.history.HistoryImportResult; import dev.wander.android.opentagviewer.util.history.HistoryImportRow; +import dev.wander.android.opentagviewer.util.history.HistoryImportProgress; /** The atomic database half of restoring a history archive. */ @Dao @@ -39,13 +40,17 @@ default HistoryImportResult merge( final List rows, final int rowsRead, final int malformedRows, - final long now) { + final long now, + final HistoryImportProgress progress) { final Map activeBeacons = new HashMap<>(); final Map> heldTimestamps = new HashMap<>(); int added = 0; int alreadyPresent = 0; int unknown = 0; + int processed = 0; + + progress.changed(HistoryImportProgress.Stage.MERGING, 0, rows.size()); for (HistoryImportRow row : rows) { final String beaconId = row.getBeaconId(); @@ -53,6 +58,7 @@ default HistoryImportResult merge( beaconId, this::isActiveBeacon); if (!active) { unknown++; + reportProgress(progress, ++processed, rows.size()); continue; } @@ -60,6 +66,7 @@ default HistoryImportResult merge( beaconId, id -> new HashSet<>(this.timestampsFor(id))); if (!timestamps.add(row.getReport().getTimestamp())) { alreadyPresent++; + reportProgress(progress, ++processed, rows.size()); continue; } @@ -83,9 +90,20 @@ default HistoryImportResult merge( } else { added++; } + reportProgress(progress, ++processed, rows.size()); } return new HistoryImportResult( rowsRead, added, alreadyPresent, malformedRows, unknown); } + + /** Do not enqueue one main-thread update for every row in a large archive. */ + private static void reportProgress( + final HistoryImportProgress progress, + final int completed, + final int total) { + if (completed == total || completed % 1_000 == 0) { + progress.changed(HistoryImportProgress.Stage.MERGING, completed, total); + } + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java index 6cfc11fb..15642427 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AppDependencies.java @@ -16,9 +16,12 @@ import dev.wander.android.opentagviewer.anisette.LocalAnisette; import dev.wander.android.opentagviewer.python.icloud.ICloudService; import dev.wander.android.opentagviewer.python.icloud.PythonICloudService; -import dev.wander.android.opentagviewer.db.repo.model.UserSettings; -import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService; -import dev.wander.android.opentagviewer.util.android.AddressLookup; +import dev.wander.android.opentagviewer.db.repo.model.UserSettings; +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.service.web.AnisetteServerTesterService; +import dev.wander.android.opentagviewer.util.android.AddressLookup; +import dev.wander.android.opentagviewer.util.history.HistoryArchiveImporter; +import dev.wander.android.opentagviewer.util.history.HistoryImporter; /** * What the sign-in screen depends on, in one place a test can replace. @@ -92,7 +95,12 @@ public interface AnisetteFactory { * just decided to share the keys to their tags - and producing that state for real means * breaking the interpreter. A fake produces it in a line. */ - private static BundleBuilder bundleBuilder = new ChaquopyBundleBuilder(); + private static BundleBuilder bundleBuilder = new ChaquopyBundleBuilder(); + + /** Builds the blocking history importer for the My Devices screen. */ + private static Function historyImporterFactory = + context -> new HistoryImporter(OpenTagViewerDatabase.getInstance( + context.getApplicationContext())); /** * Turns coordinates into something a person recognises. @@ -173,9 +181,13 @@ public static LogRedactor logRedactor() { return logRedactor; } - public static BundleBuilder bundleBuilder() { - return bundleBuilder; - } + public static BundleBuilder bundleBuilder() { + return bundleBuilder; + } + + public static HistoryArchiveImporter historyImporter(final Context context) { + return historyImporterFactory.apply(context); + } public static AnisetteServerTesterService serverTester(final CronetEngine engine) { return serverTesterFactory.apply(engine); @@ -207,9 +219,14 @@ public static void replaceLogRedactor(final LogRedactor replacement) { } @VisibleForTesting - public static void replaceBundleBuilder(final BundleBuilder replacement) { - bundleBuilder = replacement; - } + public static void replaceBundleBuilder(final BundleBuilder replacement) { + bundleBuilder = replacement; + } + + @VisibleForTesting + public static void replaceHistoryImporter(final HistoryArchiveImporter replacement) { + historyImporterFactory = context -> replacement; + } @VisibleForTesting public static void replaceAnisette(final Function replacement) { @@ -224,7 +241,9 @@ public static void reset() { serverTesterFactory = AnisetteServerTesterService::new; hardwareDescriber = new ChaquopyHardwareDescriber(); logRedactor = new ChaquopyLogRedactor(); - bundleBuilder = new ChaquopyBundleBuilder(); + bundleBuilder = new ChaquopyBundleBuilder(); + historyImporterFactory = context -> new HistoryImporter( + OpenTagViewerDatabase.getInstance(context.getApplicationContext())); icloudFactory = AppDependencies::openRealICloud; geocoderFactory = (context, locale) -> AddressLookup.through(new Geocoder(context, locale)); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryArchiveImporter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryArchiveImporter.java new file mode 100644 index 00000000..b6ac886a --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryArchiveImporter.java @@ -0,0 +1,13 @@ +package dev.wander.android.opentagviewer.util.history; + +import androidx.annotation.NonNull; + +import java.io.InputStream; + +/** The blocking history-restore operation used by the My Devices screen. */ +@FunctionalInterface +public interface HistoryArchiveImporter { + HistoryImportResult importArchive( + @NonNull InputStream archive, + @NonNull HistoryImportProgress progress) throws HistoryImportException; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java index 5b522eed..137c9c7e 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportException.java @@ -10,6 +10,7 @@ public enum Reason { INVALID_ARCHIVE, READ_FAILED, DATABASE_FAILED, + UNEXPECTED, } private final Reason reason; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportProgress.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportProgress.java new file mode 100644 index 00000000..93af7ec1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportProgress.java @@ -0,0 +1,15 @@ +package dev.wander.android.opentagviewer.util.history; + +/** Progress from the blocking archive reader and database merge. */ +@FunctionalInterface +public interface HistoryImportProgress { + enum Stage { + READING, + MERGING, + } + + HistoryImportProgress NONE = (stage, completed, total) -> {}; + + /** Total is unknown while reading and is therefore zero for that stage. */ + void changed(Stage stage, int completed, int total); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java index 1f2e3945..91909152 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImportSink.java @@ -9,5 +9,6 @@ HistoryImportResult merge( List rows, int rowsRead, int malformedRows, - long now); + long now, + HistoryImportProgress progress); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java index d4ab2e58..a7163304 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java @@ -15,6 +15,7 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -28,7 +29,7 @@ import dev.wander.android.opentagviewer.util.export.HistoryCsvWriter; /** Restores an Android history-export ZIP through one blocking interface. */ -public final class HistoryImporter { +public final class HistoryImporter implements HistoryArchiveImporter { private static final String BEACON_ID = "beacon_id"; private static final CSVFormat CSV = CSVFormat.RFC4180.builder() @@ -57,10 +58,19 @@ public HistoryImporter(@NonNull final OpenTagViewerDatabase db) { public HistoryImportResult importArchive(@NonNull final InputStream archive) throws HistoryImportException { + return this.importArchive(archive, HistoryImportProgress.NONE); + } + + @Override + public HistoryImportResult importArchive( + @NonNull final InputStream archive, + @NonNull final HistoryImportProgress progress) throws HistoryImportException { + + progress.changed(HistoryImportProgress.Stage.READING, 0, 0); final ReadResult read = this.readArchive(archive); try { return this.sink.merge( - read.rows, read.rowsRead, read.malformedRows, this.clock.getAsLong()); + read.rows, read.rowsRead, read.malformedRows, this.clock.getAsLong(), progress); } catch (RuntimeException error) { throw new HistoryImportException( HistoryImportException.Reason.DATABASE_FAILED, @@ -97,10 +107,9 @@ private ReadResult readArchive(final InputStream archive) throws HistoryImportEx "History archive could not be read", error); } catch (RuntimeException error) { - // Commons CSV reports some malformed record shapes while its iterator advances. throw new HistoryImportException( - HistoryImportException.Reason.INVALID_ARCHIVE, - "History CSV structure is invalid", + HistoryImportException.Reason.UNEXPECTED, + "History import failed unexpectedly while reading the archive", error); } @@ -142,7 +151,7 @@ public void close() { for (CSVRecord record : parser) { result.rowsRead++; - final HistoryImportRow row = parseRow(record); + final HistoryImportRow row = parseRow(record, result.beaconIds); if (row == null) { result.malformedRows++; } else { @@ -152,12 +161,19 @@ public void close() { } } - private static HistoryImportRow parseRow(final CSVRecord row) { + private static HistoryImportRow parseRow( + final CSVRecord row, + final Map beaconIds) { try { - final String beaconId = row.get(BEACON_ID); - if (beaconId == null || beaconId.isBlank()) { + final String readBeaconId = row.get(BEACON_ID); + if (readBeaconId == null || readBeaconId.isBlank()) { return null; } + // A multi-year archive repeats one UUID tens of thousands of times. Keep one String + // object per beacon rather than one per row while the full archive is held for its + // atomic merge. + final String beaconId = beaconIds.computeIfAbsent( + readBeaconId, ignored -> readBeaconId); final double latitude = Double.parseDouble(row.get("latitude_exact")); final double longitude = Double.parseDouble(row.get("longitude_exact")); @@ -198,6 +214,7 @@ private static String parseDescription(final CSVRecord row) { private static final class ReadResult { private final List rows = new ArrayList<>(); + private final Map beaconIds = new HashMap<>(); private int rowsRead; private int malformedRows; } diff --git a/app/src/main/res/layout/history_import_progress.xml b/app/src/main/res/layout/history_import_progress.xml new file mode 100644 index 00000000..b5af8731 --- /dev/null +++ b/app/src/main/res/layout/history_import_progress.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6e25788b..62feb01d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -323,4 +323,7 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Wähle eine unbeschädigte ZIP-Datei, die mit „Verlauf exportieren“ in OpenTagViewer für Android erstellt wurde. Verlauf konnte nicht importiert werden Es wurde kein Verlauf importiert. Versuche es erneut oder wähle eine andere Verlaufs-ZIP. + Die App konnte den Verlauf aus der ausgewählten Datei nicht wiederherstellen und kann nicht feststellen, warum — ein erneuter Versuch mit derselben Datei hilft daher möglicherweise nicht. + +Es wurde kein Verlauf importiert und bereits gespeicherte Daten wurden nicht geändert. \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 38b09df3..0513d56e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -323,4 +323,7 @@ You can set this up now, or any time later from Settings. Choose an undamaged ZIP created by Export History in OpenTagViewer for Android. History could not be imported No history was imported. Try again, or choose another Export History ZIP. + The app could not restore history from the file you picked, and cannot say why — so trying again with the same file may not help. + +No history was imported and nothing already stored was changed. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index eef6b4b0..87be1bfd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -323,4 +323,7 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Choisissez un fichier ZIP intact créé par « Exporter l’historique » dans OpenTagViewer pour Android. Impossible d’importer l’historique Aucun historique n’a été importé. Réessayez ou choisissez un autre fichier ZIP d’historique. + L’application n’a pas pu restaurer l’historique depuis le fichier sélectionné et ne peut pas en déterminer la raison — réessayer avec le même fichier risque donc de ne pas fonctionner. + +Aucun historique n’a été importé et les données déjà enregistrées n’ont pas été modifiées. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 848af56e..f2086b84 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -323,4 +323,7 @@ Android 版 OpenTagViewer の「履歴をエクスポート」で作成した、破損していない ZIP を選択してください。 履歴をインポートできませんでした 履歴はインポートされませんでした。もう一度試すか、別の履歴 ZIP を選択してください。 + 選択したファイルから履歴を復元できず、アプリはその原因を特定できませんでした。そのため、同じファイルでもう一度試しても解決しない可能性があります。 + +履歴はインポートされず、すでに保存されているデータも変更されていません。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3736ed96..16f0641d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -323,4 +323,7 @@ Android용 OpenTagViewer의 기록 내보내기에서 만든 손상되지 않은 ZIP을 선택하세요. 기록을 가져올 수 없음 기록을 가져오지 않았습니다. 다시 시도하거나 다른 기록 ZIP을 선택하세요. + 선택한 파일에서 기록을 복원할 수 없었으며 앱에서 그 원인을 확인할 수 없습니다. 따라서 같은 파일로 다시 시도해도 해결되지 않을 수 있습니다. + +기록을 가져오지 않았으며 이미 저장된 데이터도 변경되지 않았습니다. \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 9e79295b..2a66dbd9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -323,4 +323,7 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Kies een onbeschadigde ZIP die is gemaakt met Geschiedenis exporteren in OpenTagViewer voor Android. Geschiedenis kon niet worden geïmporteerd Er is geen geschiedenis geïmporteerd. Probeer het opnieuw of kies een andere geschiedenis-ZIP. + De app kon de geschiedenis uit het gekozen bestand niet herstellen en kan niet bepalen waarom — opnieuw proberen met hetzelfde bestand helpt daarom mogelijk niet. + +Er is geen geschiedenis geïmporteerd en eerder opgeslagen gegevens zijn niet gewijzigd. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a4f91248..6d931bc2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -323,4 +323,7 @@ Выберите неповреждённый ZIP-файл, созданный командой «Экспорт истории» в OpenTagViewer для Android. Не удалось импортировать историю История не была импортирована. Повторите попытку или выберите другой ZIP-файл истории. + Приложению не удалось восстановить историю из выбранного файла, и определить причину невозможно — повторная попытка с тем же файлом может не помочь. + +История не была импортирована, а уже сохранённые данные не изменились. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 051ba5be..49af50a0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -323,4 +323,7 @@ 请选择由 Android 版 OpenTagViewer 的“导出历史记录”创建且未损坏的 ZIP。 无法导入历史记录 未导入任何历史记录。请重试或选择其他历史记录 ZIP。 + 应用无法从所选文件恢复历史记录,也无法确定原因,因此再次尝试同一文件可能无法解决问题。 + +未导入任何历史记录,已存储的数据也没有更改。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 45b79587..77bdee43 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -323,4 +323,7 @@ 請選擇由 Android 版 OpenTagViewer 的「匯出歷史記錄」建立且未損壞的 ZIP。 無法匯入歷史記錄 未匯入任何歷史記錄。請重試或選擇其他歷史記錄 ZIP。 + 應用程式無法從所選檔案還原歷史記錄,也無法確定原因,因此再次嘗試同一檔案可能無法解決問題。 + +未匯入任何歷史記錄,已儲存的資料也沒有變更。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9db173d4..c24a8f83 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,4 +355,7 @@ You can set this up now, or any time later from Settings. Choose an undamaged ZIP created by Export History in OpenTagViewer for Android. History could not be imported No history was imported. Try again, or choose another Export History ZIP. + The app could not restore history from the file you picked, and cannot say why — so trying again with the same file may not help. + +No history was imported and nothing already stored was changed. diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java index dad1ba9d..07c1cfd1 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java @@ -2,12 +2,15 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneId; @@ -48,7 +51,7 @@ public void whatTheAppExportsCanBeReadBackWithoutLosingAReportField() throws Exc final List received = new ArrayList<>(); final HistoryImporter importer = new HistoryImporter( - (rows, rowsRead, malformedRows, now) -> { + (rows, rowsRead, malformedRows, now, progress) -> { received.addAll(rows); return new HistoryImportResult(rowsRead, rows.size(), 0, malformedRows, 0); }, @@ -105,6 +108,20 @@ public void aMalformedRowIsCountedWithoutBlockingTheRowsAroundIt() throws Except assertEquals("good", received.get(0).getReport().getDescription()); } + @Test + public void repeatedBeaconIdsShareOneStringWhileTheArchiveIsHeld() throws Exception { + final String header = String.join(",", HistoryCsvWriter.requiredHeaders()); + final List received = new ArrayList<>(); + + importerCapturing(received).importArchive(new ByteArrayInputStream(zip( + "Wallet.csv", header + "\r\n" + + row("52.3702157", "first") + "\r\n" + + row("52.3702157", "second") + "\r\n"))); + + assertSame("the repeated UUID should be interned per archive", + received.get(0).getBeaconId(), received.get(1).getBeaconId()); + } + @Test public void headerOrderAndExtraColumnsDoNotChangeTheContract() throws Exception { final String csv = "beacon_id,description,status,confidence,horizontal_accuracy_m," @@ -166,11 +183,28 @@ public void malformedCsvSyntaxIsAWholeArchiveFailure() throws Exception { assertEquals(HistoryImportException.Reason.INVALID_ARCHIVE, error.getReason()); } + @Test + public void anUnexpectedReaderBugIsNotBlamedOnTheArchive() { + final InputStream brokenReader = new InputStream() { + @Override + public int read() throws IOException { + throw new IllegalStateException("reader invariant failed"); + } + }; + + final HistoryImportException error = assertThrows( + HistoryImportException.class, + () -> importerCapturing(new ArrayList<>()).importArchive(brokenReader)); + + assertEquals(HistoryImportException.Reason.UNEXPECTED, error.getReason()); + assertEquals("reader invariant failed", error.getCause().getMessage()); + } + @Test public void aLaterBrokenFilePreventsTheEntireArchiveReachingPersistence() throws Exception { final AtomicBoolean persistenceWasCalled = new AtomicBoolean(false); final HistoryImporter importer = new HistoryImporter( - (rows, rowsRead, malformedRows, now) -> { + (rows, rowsRead, malformedRows, now, progress) -> { persistenceWasCalled.set(true); return new HistoryImportResult(rowsRead, rows.size(), 0, malformedRows, 0); }, @@ -194,7 +228,7 @@ public void aLaterBrokenFilePreventsTheEntireArchiveReachingPersistence() throws @Test public void databaseFailureHasItsOwnTypedResult() throws Exception { final HistoryImporter importer = new HistoryImporter( - (rows, rowsRead, malformedRows, now) -> { + (rows, rowsRead, malformedRows, now, progress) -> { throw new IllegalStateException("database unavailable"); }, () -> 1_800_000_000_000L); @@ -210,7 +244,7 @@ public void databaseFailureHasItsOwnTypedResult() throws Exception { private static HistoryImporter importerCapturing(final List received) { return new HistoryImporter( - (rows, rowsRead, malformedRows, now) -> { + (rows, rowsRead, malformedRows, now, progress) -> { received.addAll(rows); return new HistoryImportResult(rowsRead, rows.size(), 0, malformedRows, 0); }, From 0bd848ae5fb14f9622651df2f2cf08a07b42ad61 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sat, 12 Sep 2026 13:52:14 +0200 Subject: [PATCH 3/4] Stop a picked file that will not open reaching the bug page CI on this branch ran for 45 minutes and was killed. Two faults, and the one that mattered was not the one that showed up red. **The suite hung in readFailureUsesTheGenericFailureMessage.** The per- test logcats put it beyond doubt: every other test in that class finished by 17:51:24, and that one's logcat was written at 18:21:36, 176 KB of device output, at the moment the job timed out. Opening the document happens in the Activity, outside importArchive, so a file that has gone arrives as a bare IOException rather than a HistoryImportException. historyImportFailed reads that as something this app did not anticipate and opens ErrorReportActivity - which is right for a defect here and wrong for a file the picker handed back. The dialog the test waits for never appeared, and inRoot(isDialog()) against a screen with no dialog is the case Espresso retries internally for seconds at a time. AGENTS.md describes that under Eventually; seven tests once took six and a half minutes to it. READ_FAILED already existed for this and was not being used. Opening is now wrapped in it, so the failure reaches the plain dialog rather than the report page, and the test passes because the behaviour is right. **And the layout test inflated by a route the app never takes.** It built a themed context by hand and called LayoutInflater.from on it, which is the SystemColorsLayoutTest pattern - but nothing there inflates a Material progress indicator, and an AppCompatActivity installs a factory a bare ContextThemeWrapper does not. It threw InflateException while the dialog worked on a device. It now inflates through TestHostActivity's own inflater, with cloneInContext carrying a night configuration for the second render, so both themes are covered without touching global state. Rule 12 makes this exact point about drawables: load them the way the app loads them. The progress dialog itself was never broken. aLongImportShowsAndUpdatesProgressBeforeItsResult passed at 17:51:22. Co-Authored-By: Claude Opus 5 (1M context) --- .../HistoryImportProgressLayoutTest.java | 83 ++++++++++++------- ...ImportingHistoryFromTheDeviceListTest.java | 12 +++ .../opentagviewer/MyDevicesListActivity.java | 20 ++++- 3 files changed, 85 insertions(+), 30 deletions(-) diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java index 4c85cd4a..0236a668 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/HistoryImportProgressLayoutTest.java @@ -1,6 +1,5 @@ package dev.wander.android.opentagviewer.ui.mydevices; -import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -10,6 +9,7 @@ import android.view.View; import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.core.app.ActivityScenario; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.material.progressindicator.CircularProgressIndicator; @@ -18,41 +18,68 @@ import org.junit.runner.RunWith; import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.ui.TestHostActivity; -/** Cheap inflation coverage for the custom view placed inside the import dialog. */ +/** + * Inflation coverage for the view inside the history-import dialog. + * + *

Through an Activity's own inflater, because that is what the app uses. The first + * version built a themed context by hand and called {@code LayoutInflater.from(...)} on it - the + * pattern {@code SystemColorsLayoutTest} uses - and threw {@code InflateException} on the + * {@code CircularProgressIndicator}, while the dialog worked perfectly well on a device. Nothing + * in {@code SystemColorsLayoutTest} inflates a Material progress indicator, so that pattern had + * never been asked to; an {@code AppCompatActivity} installs an inflater factory that a bare + * {@code ContextThemeWrapper} does not. + * + *

Rule 12 makes the same point about drawables: load them the way the app loads them. A + * test that inflates by a route the app never takes can fail for reasons the app will never meet, + * and can pass while the real path is broken. + * + *

{@code cloneInContext} is what allows both themes without touching global state: it keeps the + * Activity's factory and swaps only the configuration, so the dark render is a second inflation + * rather than a second Activity. + */ @RunWith(AndroidJUnit4.class) public class HistoryImportProgressLayoutTest { @Test public void itInflatesAndMeasuresInBothThemes() { - for (final boolean night : new boolean[]{false, true}) { - final View root = inflate(night); - final CircularProgressIndicator indicator = - root.findViewById(R.id.history_import_progress); - - assertNotNull(indicator); - assertTrue(indicator.isIndeterminate()); - - root.measure( - View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); - root.layout(0, 0, root.getMeasuredWidth(), root.getMeasuredHeight()); - - assertTrue("the progress dialog content measured to nothing", - root.getMeasuredHeight() > 0); - assertTrue("the progress indicator measured to nothing", - indicator.getMeasuredWidth() > 0 && indicator.getMeasuredHeight() > 0); + try (ActivityScenario scenario = + ActivityScenario.launch(TestHostActivity.class)) { + scenario.onActivity(activity -> { + checkItInflates(activity.getLayoutInflater()); + checkItInflates(activity.getLayoutInflater().cloneInContext(inNightMode(activity))); + }); } } - private static View inflate(final boolean night) { - final Context base = getInstrumentation().getTargetContext(); - final Configuration configuration = new Configuration( - base.getResources().getConfiguration()); - configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) - | (night ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO); - final Context themed = new ContextThemeWrapper( - base.createConfigurationContext(configuration), R.style.Theme_OpenTagViewer); - return LayoutInflater.from(themed).inflate(R.layout.history_import_progress, null); + /** The same layout, the same assertions, whichever configuration the inflater carries. */ + private static void checkItInflates(final LayoutInflater inflater) { + final View root = inflater.inflate(R.layout.history_import_progress, null); + final CircularProgressIndicator indicator = + root.findViewById(R.id.history_import_progress); + + assertNotNull("the id the activity looks up has to resolve", indicator); + assertTrue("it is indeterminate until the merge phase gives it a total", + indicator.isIndeterminate()); + + root.measure( + View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + root.layout(0, 0, root.getMeasuredWidth(), root.getMeasuredHeight()); + + assertTrue("the dialog content measured to nothing", root.getMeasuredHeight() > 0); + assertTrue("the progress indicator measured to nothing", + indicator.getMeasuredWidth() > 0 && indicator.getMeasuredHeight() > 0); + } + + /** The app's theme, in a configuration that says night, without changing the device. */ + private static Context inNightMode(final Context base) { + final Configuration night = new Configuration(base.getResources().getConfiguration()); + night.uiMode = (night.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) + | Configuration.UI_MODE_NIGHT_YES; + + return new ContextThemeWrapper( + base.createConfigurationContext(night), R.style.Theme_OpenTagViewer); } } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java index cefb406c..515d11f6 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java @@ -270,6 +270,18 @@ public void damagedOrUnsupportedArchiveGetsItsOwnExplanation() throws Exception intended(hasComponent(ErrorReportActivity.class.getName()), times(0)); } + /** + * A file the picker handed back that will not open is the user's situation, not our defect. + * + *

This hung the whole suite for thirty minutes. Opening happens in the Activity, + * outside {@code importArchive}, so a missing file arrived as a bare {@code IOException} - + * not a {@code HistoryImportException} - and {@code historyImportFailed} sent it to the bug + * report page. The dialog this waits for never appeared, and {@code inRoot(isDialog())} + * against a screen with no dialog is the slow case Espresso retries internally for seconds + * at a time. See the note on {@code Eventually} in AGENTS.md. + * + *

So the assertion below is also the assertion that the bug page is not reached. + */ @Test public void readFailureUsesTheGenericFailureMessage() { final File missing = new File( diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java index 2170dc75..023f540c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MyDevicesListActivity.java @@ -672,9 +672,25 @@ private void showPageMenu() { private void importHistory(@NonNull final Uri uri) { this.showHistoryImportProgress(); Observable.fromCallable(() -> { - final InputStream opened = this.getContentResolver().openInputStream(uri); + // **Opening is READ_FAILED, not an unexpected failure.** A document the user + // picked that has since gone, or a provider that hands back nothing, is an + // ordinary thing for a file picker to produce - and left unwrapped it is not + // a HistoryImportException at all, so historyImportFailed takes it for + // something this app did not anticipate and opens the bug report page. The + // page is for defects here; a missing file is not one. + final InputStream opened; + try { + opened = this.getContentResolver().openInputStream(uri); + } catch (final Exception cannotOpen) { + throw new HistoryImportException( + HistoryImportException.Reason.READ_FAILED, + "The history archive could not be opened", + cannotOpen); + } if (opened == null) { - throw new IOException("The document provider returned no history data"); + throw new HistoryImportException( + HistoryImportException.Reason.READ_FAILED, + "The document provider returned no history data"); } return AppDependencies.historyImporter(this.getApplicationContext()) .importArchive(opened, this::historyImportProgressChanged); From 6bc4dd2e7ad026441e57943a280af2a74a4b79c7 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sat, 12 Sep 2026 18:30:23 +0200 Subject: [PATCH 4/4] Carry a report's provenance through the export and back #139 added `LocationReport.provenance` and said in its own docstring that the column exists because the history is exported - "without it the CSV hands somebody a file where their own phone's positions sit unlabelled among Apple's, and nothing in the file says which is which". The writer was never updated, and this branch's importer builds its rows without the field at all, which is a `NOT NULL` column: Room refuses the insert and takes the whole merge transaction with it. Five instrumented tests fail on it, and none of them could have failed before, because each PR was tested against a main without the other. So the round trip now carries it: - `BeaconLocationReport` gains the field, because the export reads models rather than rows, and the three mappings in `BeaconRepository` carry it in both directions - `HistoryCsvWriter` writes a `provenance` column. An Apple row is a stranger's iPhone estimating a position to within a hundred metres or worse; a `local` row is this phone hearing the tag directly and recording its own position as the tag's. Unlabelled, the second reads as the first - `HistoryImporter` requires the column and refuses a row whose value is neither, rather than storing a third kind of report that nothing downstream has a branch for - `HistoryImportDao.merge` sets it, which is the crash **The column is required rather than optional**, and that is free exactly once: the history export shipped in no release - `util/export/` does not exist in `android-app-v1.0.5` - so there are no archives in the field to stay compatible with. Adding it after 1.1.0 would have meant accepting both shapes forever. The two round-trip tests now carry it, and the first uses `local` on purpose: it is the value a missing column does not fall back to, so it cannot pass against a writer or reader that defaults the field into place. --- .../db/room/HistoryImportTest.java | 3 + ...ImportingHistoryFromTheDeviceListTest.java | 3 +- .../data/model/BeaconLocationReport.java | 16 +++++ .../db/repo/BeaconRepository.java | 5 ++ .../db/room/dao/HistoryImportDao.java | 7 ++ .../util/export/HistoryCsvWriter.java | 13 ++++ .../util/history/HistoryImporter.java | 31 ++++++++ .../util/export/HistoryCsvWriterTest.java | 30 +++++++- .../util/history/HistoryImporterTest.java | 72 ++++++++++++++++++- 9 files changed, 175 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java index b0bcde3f..b55de010 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/HistoryImportTest.java @@ -149,6 +149,9 @@ private static BeaconLocationReport report(final long timestamp, final String de .confidence(1) .status(0) .description(description) + // The archive these tests build goes through the real writer, so every row needs + // the column the reader now requires - see HistoryCsvWriter.HEADERS. + .provenance(LocationReport.PROVENANCE_APPLE) .build(); } diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java index 515d11f6..51f9db87 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/mydevices/ImportingHistoryFromTheDeviceListTest.java @@ -60,6 +60,7 @@ import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.db.room.entity.BeaconNamingRecord; import dev.wander.android.opentagviewer.db.room.entity.Import; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; @@ -374,7 +375,7 @@ private static String row( return "2026-08-15T12:34:56Z,2026-08-15 12:34:56Z," + timestamp + "," + latitude + ",4.8951679,12,2,1,2026-08-15T12:35:56Z," + description + "," + (timestamp + 60_000L) + "," + latitude - + ",4.8951679,true," + beaconId; + + ",4.8951679,true," + LocationReport.PROVENANCE_APPLE + "," + beaconId; } private void forgetTestData() { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconLocationReport.java b/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconLocationReport.java index 099c7c89..49b6d73d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconLocationReport.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/data/model/BeaconLocationReport.java @@ -1,5 +1,7 @@ package dev.wander.android.opentagviewer.data.model; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; + import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -65,4 +67,18 @@ public class BeaconLocationReport { * Status byte of the accessory as recorded by a device, as an integer. */ private long status; + + /** + * Where this report came from: {@link LocationReport#PROVENANCE_APPLE} or + * {@link LocationReport#PROVENANCE_LOCAL}. + * + *

On the model and not only on the row, because the history export reads this. The + * CSV is handed to a person, and a position this phone worked out for itself sitting + * unlabelled among Apple's is a file that misrepresents where half of it came from - see the + * field on the entity, which says the same thing from the other side. + * + *

Null only on a report that has not been through the database. Everything read out of it + * carries one, because the column is {@code NOT NULL}. + */ + private String provenance; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java index 3b9e69ea..b6514156 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java @@ -708,6 +708,7 @@ public Observable> recordLocalSighting( .longitude(longitude) .horizontalAccuracy(accuracyMetres) .status(statusByte) + .provenance(LocationReport.PROVENANCE_LOCAL) .build(); dao.insertAll(LocationReport.builder() @@ -989,6 +990,7 @@ public Observable> getLastLocationsForAll() { .longitude(locationReport.longitude) .horizontalAccuracy(locationReport.horizontalAccuracy) .status(locationReport.status) + .provenance(locationReport.provenance) .build() ); } @@ -1009,6 +1011,9 @@ public Observable> getLocationsFor(final String beaco .longitude(locationReport.longitude) .horizontalAccuracy(locationReport.horizontalAccuracy) .status(locationReport.status) + // The export reads this list. A row whose provenance is dropped here is a + // CSV that claims Apple found something this phone did. + .provenance(locationReport.provenance) .build()) .collect(Collectors.toList()); }).subscribeOn(Schedulers.io()); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java index 20cf0eee..f12d82c0 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/HistoryImportDao.java @@ -83,6 +83,13 @@ default HistoryImportResult merge( .horizontalAccuracy(row.getReport().getHorizontalAccuracy()) .status(row.getReport().getStatus()) .lastUpdate(now) + // **Not defaulted, and not optional.** The column is NOT NULL, so a row + // built without this is refused by Room and takes the whole transaction with + // it - which is how the archive restores nothing and reports an error the + // user cannot act on. The value comes off the CSV; see + // HistoryImporter.parseProvenance for why a row without a readable one never + // reaches here. + .provenance(row.getReport().getProvenance()) .build(); if (this.insert(stored) == -1L) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java index ed602f02..119ef929 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriter.java @@ -10,6 +10,7 @@ import java.util.List; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; /** * Writes a tag's location history as CSV. @@ -55,6 +56,13 @@ public final class HistoryCsvWriter { "latitude_exact", "longitude_exact", "description_present", + // **Where the row came from, and it is not a technicality.** An Apple row says a + // stranger's iPhone overheard the tag and estimated a position, typically to within + // a hundred metres or worse. A `local` row says this phone heard the tag directly, + // which puts it within Bluetooth range and records this phone's own position as the + // tag's. Unlabelled, the second reads as the first, and the file misrepresents half + // of itself to whoever opens it. + "provenance", // Last because it is for restoring this file, not for reading it in a spreadsheet. // Stable identity must live in the contents: display names and filenames can both // change, and two tags may have the same one. @@ -127,6 +135,11 @@ private String toRow(final String beaconId, final BeaconLocationReport report) { Double.toString(report.getLatitude()), Double.toString(report.getLongitude()), Boolean.toString(report.getDescription() != null), + // Never blank. Every row read out of the database has one, and a row that + // somehow does not is written as what it almost certainly is rather than as + // an empty column the restore side would have to guess at. + escape(report.getProvenance() == null + ? LocationReport.PROVENANCE_APPLE : report.getProvenance()), escape(beaconId)); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java index a7163304..e05bd0ce 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/history/HistoryImporter.java @@ -25,12 +25,14 @@ import java.util.zip.ZipInputStream; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; import dev.wander.android.opentagviewer.util.export.HistoryCsvWriter; /** Restores an Android history-export ZIP through one blocking interface. */ public final class HistoryImporter implements HistoryArchiveImporter { private static final String BEACON_ID = "beacon_id"; + private static final String PROVENANCE = "provenance"; private static final CSVFormat CSV = CSVFormat.RFC4180.builder() .setHeader() @@ -193,14 +195,43 @@ private static HistoryImportRow parseRow( .confidence(Long.parseLong(row.get("confidence"))) .status(Long.parseLong(row.get("status"))) .description(parseDescription(row)) + .provenance(parseProvenance(row)) .build(); + if (report.getProvenance() == null) { + return null; + } + return new HistoryImportRow(beaconId, report); } catch (IllegalArgumentException error) { return null; } } + /** + * Where the archive says this row came from, refused if it is not something the app knows. + * + *

Refused rather than defaulted. The column is {@code NOT NULL} and everything + * that draws a tag reads it, so an unrecognised value is not a cosmetic problem: it reaches + * the map, the history and the "last updated" line as a third kind of report that none of + * them have a branch for. Returning null here makes the row malformed, which is counted and + * reported to the user rather than swallowed. + * + *

The file is trusted about this, and that is a deliberate limit. A hand-edited CSV can + * claim a local sighting was Apple's; there is no way to tell from the file, and the + * alternative - stamping every restored row as one thing - throws away the true answer for + * every ordinary restore in order to defend against somebody editing their own data. + */ + private static String parseProvenance(final CSVRecord row) { + final String read = row.get(PROVENANCE); + + if (LocationReport.PROVENANCE_APPLE.equals(read) + || LocationReport.PROVENANCE_LOCAL.equals(read)) { + return read; + } + return null; + } + private static String parseDescription(final CSVRecord row) { final String present = row.get("description_present"); if ("false".equals(present)) { diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java index c726fd32..5798b51a 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/export/HistoryCsvWriterTest.java @@ -13,6 +13,7 @@ import java.util.Locale; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; /** * The CSV a user opens in a spreadsheet. @@ -46,7 +47,34 @@ private static BeaconLocationReport.BeaconLocationReportBuilder report() { .horizontalAccuracy(12) .confidence(2) .status(1) - .description("Amsterdam"); + .description("Amsterdam") + .provenance(LocationReport.PROVENANCE_APPLE); + } + + /** + * A locally heard sighting is labelled as one in the file somebody opens. + * + *

Without it the CSV puts this phone's own positions - accurate to Bluetooth range, and + * derived from where the phone was - among Apple's network estimates with nothing to + * tell them apart, which is what the column on {@code LocationReport} exists to prevent. + */ + @Test + public void alocallyHeardSightingSaysSoInTheFile() throws IOException { + final String csv = write(List.of( + report().provenance(LocationReport.PROVENANCE_LOCAL).build())); + final String[] lines = csv.split("\r\n"); + final int column = List.of(HistoryCsvWriter.HEADERS).indexOf("provenance"); + + assertEquals(LocationReport.PROVENANCE_LOCAL, lines[1].split(",")[column]); + } + + @Test + public void anAppleReportSaysThatInstead() throws IOException { + final String csv = write(List.of(report().build())); + final String[] lines = csv.split("\r\n"); + final int column = List.of(HistoryCsvWriter.HEADERS).indexOf("provenance"); + + assertEquals(LocationReport.PROVENANCE_APPLE, lines[1].split(",")[column]); } private static String write(final List reports) throws IOException { diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java index 07c1cfd1..12268b0f 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/history/HistoryImporterTest.java @@ -22,6 +22,7 @@ import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import dev.wander.android.opentagviewer.util.export.HistoryExportEntry; +import dev.wander.android.opentagviewer.db.room.entity.LocationReport; import dev.wander.android.opentagviewer.util.export.HistoryCsvWriter; import dev.wander.android.opentagviewer.util.export.HistoryZipWriter; @@ -43,6 +44,9 @@ public void whatTheAppExportsCanBeReadBackWithoutLosingAReportField() throws Exc .confidence(2) .status(1) .description("Hiraistraat 9D, \"Amsterdam\"\r\nsecond line") + // Deliberately the value that is *not* what a missing column falls back to, so + // this cannot pass against a writer or reader that silently defaults it. + .provenance(LocationReport.PROVENANCE_LOCAL) .build(); final ByteArrayOutputStream archive = new ByteArrayOutputStream(); new HistoryZipWriter(ZoneId.of("Europe/Amsterdam")).write( @@ -78,6 +82,7 @@ public void anAbsentDescriptionStaysAbsentRatherThanBecomingEmptyText() throws E .confidence(2) .status(1) .description(null) + .provenance(LocationReport.PROVENANCE_APPLE) .build(); final ByteArrayOutputStream archive = new ByteArrayOutputStream(); new HistoryZipWriter(ZoneId.of("UTC")).write( @@ -122,16 +127,72 @@ public void repeatedBeaconIdsShareOneStringWhileTheArchiveIsHeld() throws Except received.get(0).getBeaconId(), received.get(1).getBeaconId()); } + /** + * A locally heard sighting survives the round trip as one. + * + *

The failure this catches is quiet: restoring it as {@code apple} produces a row the app + * will happily draw, claiming Apple's network found a tag that this phone heard itself. + */ + @Test + public void alocalSightingIsRestoredAsALocalSighting() throws Exception { + final String header = String.join(",", HistoryCsvWriter.requiredHeaders()); + final List received = new ArrayList<>(); + + importerCapturing(received).importArchive(new ByteArrayInputStream(zip( + "Wallet.csv", header + "\r\n" + + row("52.3702157", "heard-here", LocationReport.PROVENANCE_LOCAL) + + "\r\n"))); + + assertEquals(LocationReport.PROVENANCE_LOCAL, + received.get(0).getReport().getProvenance()); + } + + /** + * An unrecognised provenance is a malformed row, not a row with a surprising column. + * + *

The column is {@code NOT NULL} and every screen that draws a tag reads it, so a third + * value reaches all of them as a kind of report none has a branch for. + */ + @Test + public void arowClaimingSomethingElseEntirelyIsRefused() throws Exception { + final String header = String.join(",", HistoryCsvWriter.requiredHeaders()); + final List received = new ArrayList<>(); + + final HistoryImportResult result = importerCapturing(received).importArchive( + new ByteArrayInputStream(zip("Wallet.csv", header + "\r\n" + + row("52.3702157", "odd", "somewhere-else") + "\r\n" + + row("52.3702157", "fine") + "\r\n"))); + + assertEquals(1, result.getRowsMalformed()); + assertEquals(1, result.getRowsAdded()); + assertEquals("fine", received.get(0).getReport().getDescription()); + } + + /** An archive from before the column existed is not importable, and says so. */ + @Test + public void anArchiveWithNoProvenanceColumnIsRefusedAsInvalid() throws Exception { + final String header = String.join(",", HistoryCsvWriter.requiredHeaders()) + .replace(",provenance", ""); + final String withoutIt = row("52.3702157", "old").replace(",apple,", ","); + + final HistoryImportException refused = assertThrows(HistoryImportException.class, + () -> importerCapturing(new ArrayList<>()).importArchive( + new ByteArrayInputStream( + zip("Wallet.csv", header + "\r\n" + withoutIt + "\r\n")))); + + assertEquals(HistoryImportException.Reason.INVALID_ARCHIVE, refused.getReason()); + } + @Test public void headerOrderAndExtraColumnsDoNotChangeTheContract() throws Exception { final String csv = "beacon_id,description,status,confidence,horizontal_accuracy_m," + "description_present,longitude,longitude_exact,latitude,latitude_exact," + "published_at_utc,published_at_epoch_ms,timestamp_epoch_ms,timestamp_local," - + "timestamp_utc,future_column\r\n" + + "timestamp_utc,provenance,future_column\r\n" + BEACON_ID + ",somewhere,1,2,12,true,4.8951679,4.895167912345678," + "52.3702157,52.37021571234567,2026-08-15T12:35:56Z," + (RECORDED_AT + 60_000L) + "," + RECORDED_AT + "," - + "2026-08-15 14:34:56+02:00,2026-08-15T12:34:56Z,ignored\r\n"; + + "2026-08-15 14:34:56+02:00,2026-08-15T12:34:56Z,apple,ignored\r\n"; final HistoryImportResult result = importerCapturing(new ArrayList<>()).importArchive( new ByteArrayInputStream(zip("Wallet.csv", csv))); @@ -252,11 +313,16 @@ private static HistoryImporter importerCapturing(final List re } private static String row(final String latitude, final String description) { + return row(latitude, description, LocationReport.PROVENANCE_APPLE); + } + + private static String row( + final String latitude, final String description, final String provenance) { return "2026-08-15T12:34:56Z,2026-08-15 14:34:56+02:00," + RECORDED_AT + "," + latitude + ",4.8951679,12,2,1," + "2026-08-15T12:35:56Z," + description + "," + (RECORDED_AT + 60_000L) + "," + latitude + ",4.8951679,true," - + BEACON_ID; + + provenance + "," + BEACON_ID; } private static byte[] zip(final String name, final String contents) throws Exception {