diff --git a/CHANGELOG.md b/CHANGELOG.md index edf1edf..f0faf86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to mobileGF2logger are documented here. -## Unreleased +## 2.2.0 - 2026-08-11 ### Added @@ -50,7 +50,9 @@ All notable changes to mobileGF2logger are documented here. database/quarantine state after a failed import, undo, or process death. - Bound weekly PNG dimensions, pixel count, row count, and private-note length; preserve pending save state across activity recreation, and share only through - a cache-scoped non-exported `FileProvider` grant. + a cache-scoped non-exported `FileProvider` grant. Give every render a fresh + UUID-backed cache identity and revoke prior URI grants before removing stale + files so an earlier recipient cannot read a later privacy projection. - Restrict Discord destinations to canonical HTTPS `discord.com` incoming webhook URLs, disallow redirects, and bound request/response sizes and timeouts. diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt index 22beadc..7b478cf 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -3,6 +3,7 @@ package dev.gf2log.app.management import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase +import androidx.core.content.FileProvider import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -27,6 +28,7 @@ import java.util.zip.ZipException import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before @@ -800,13 +802,71 @@ class PlatoonBackupManagerIntegrationTest { } @RunWith(AndroidJUnit4::class) class WeeklyReportActivityStateTest { + @Test + fun repeatedWeeklyPngRendersUseDifferentProviderUris() { + val context = ApplicationProvider.getApplicationContext() + val periodStart = LocalDate.of(2026, 8, 9) + val document = WeeklyShareProjection.Document( + title = "GF2logger", + subtitle = "2026-08-09 - 2026-08-15", + headers = listOf("Member", "08/09", "Total"), + rows = emptyList(), + includeNotes = false, + evidenceHealth = WeeklyEvidenceAnalyzer.Health( + observedDays = 0, + totalDays = 7, + exactMetrics = 0, + lowerBoundMetrics = 0, + unknownMetrics = 0, + directLoginDays = 0, + directPatrolDays = 0, + closingBoundaries = 0, + ), + ) + val writeMethod = WeeklyReportActivity::class.java.getDeclaredMethod( + "writeWeeklyPng", + WeeklyShareProjection.Document::class.java, + LocalDate::class.java, + ).apply { isAccessible = true } + try { + ActivityScenario.launch(WeeklyReportActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val first = writeMethod.invoke(activity, document, periodStart) as java.io.File + val firstUri = FileProvider.getUriForFile( + activity, + activity.packageName + ".fileprovider", + first, + ) + val second = writeMethod.invoke(activity, document, periodStart) as java.io.File + val secondUri = FileProvider.getUriForFile( + activity, + activity.packageName + ".fileprovider", + second, + ) + + assertFalse(first.exists()) + assertNotEquals(first.canonicalPath, second.canonicalPath) + assertNotEquals(firstUri, secondUri) + activity.contentResolver.openInputStream(secondUri).use { input -> + assertTrue(input != null && input.read() >= 0) + } + } + } + } finally { + WeeklyPngPendingState.directory(context.cacheDir) + .listFiles() + .orEmpty() + .forEach(java.io.File::delete) + } + } + @Test fun pendingWeeklyPngSurvivesActivityRecreation() { val context = ApplicationProvider.getApplicationContext() - val target = java.io.File( - WeeklyPngPendingState.directory(context.cacheDir).apply { mkdirs() }, - "GF2logger-week-20260809.png", - ).apply { writeBytes(byteArrayOf(1, 2, 3)) } + WeeklyPngPendingState.directory(context.cacheDir).mkdirs() + val target = WeeklyPngPendingState + .newRenderTarget(context.cacheDir, LocalDate.of(2026, 8, 9)) + .apply { writeBytes(byteArrayOf(1, 2, 3)) } val field = WeeklyReportActivity::class.java.getDeclaredField("pendingPng").apply { isAccessible = true } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 66ad9f6..8a5124a 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -57,12 +57,29 @@ import java.io.FileOutputStream import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.UUID import java.util.concurrent.Executors /** Keeps a pending weekly PNG bound to the app-private share cache across recreation. */ internal object WeeklyPngPendingState { fun directory(cacheDirectory: File): File = File(cacheDirectory, DIRECTORY_NAME) + fun newRenderTarget(cacheDirectory: File, periodStart: LocalDate): File { + val root = directory(cacheDirectory).canonicalFile + require(root.isDirectory) { "Weekly PNG cache is unavailable" } + val candidate = File( + root, + "GF2logger-week-${periodStart.format(DATE)}-${UUID.randomUUID()}.png", + ).canonicalFile + check(candidate.parentFile == root && !candidate.exists()) { + "Weekly PNG target must be a new private cache file" + } + return candidate + } + + fun exportName(periodStart: LocalDate): String = + "GF2logger-week-${periodStart.format(DATE)}.png" + fun nameForState(cacheDirectory: File, pendingFile: File?): String? = runCatching { val candidate = pendingFile?.canonicalFile ?: return@runCatching null val root = directory(cacheDirectory).canonicalFile @@ -80,7 +97,10 @@ internal object WeeklyPngPendingState { }.getOrNull() private const val DIRECTORY_NAME = "shared-weekly" - private val FILE_NAME = Regex("GF2logger-week-\\d{8}\\.png") + private val DATE = DateTimeFormatter.BASIC_ISO_DATE + private val FILE_NAME = Regex( + "GF2logger-week-\\d{8}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.png", + ) } class WeeklyReportActivity : LocalizedActivity() { @@ -1401,7 +1421,7 @@ class WeeklyReportActivity : LocalizedActivity() { if (isFinishing || isDestroyed) return@runOnUiThread result.fold( onSuccess = { file -> - if (shareAfter) shareWeeklyPng(file) else saveWeeklyPng(file) + if (shareAfter) shareWeeklyPng(file) else saveWeeklyPng(file, model.report.periodStart) }, onFailure = { Toast.makeText( @@ -1421,11 +1441,8 @@ class WeeklyReportActivity : LocalizedActivity() { ): File { val directory = WeeklyPngPendingState.directory(cacheDir).apply { mkdirs() } require(directory.isDirectory) { "Unable to create weekly share cache" } - directory.listFiles().orEmpty().forEach(File::delete) - val target = File( - directory, - "GF2logger-week-" + periodStart.format(FILE_DATE) + ".png", - ) + revokeAndDeleteOldWeeklyPngs(directory) + val target = WeeklyPngPendingState.newRenderTarget(cacheDir, periodStart) val temporary = File.createTempFile(".weekly-", ".png", directory) val bitmap = WeeklyReportPngRenderer.render(document) try { @@ -1443,6 +1460,22 @@ class WeeklyReportActivity : LocalizedActivity() { return target } + private fun revokeAndDeleteOldWeeklyPngs(directory: File) { + directory.listFiles().orEmpty() + .filter(java.io.File::isFile) + .forEach { stale -> + runCatching { + val uri = FileProvider.getUriForFile( + this, + packageName + ".fileprovider", + stale, + ) + revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + stale.delete() + } + } + private fun shareWeeklyPng(file: File) { val uri = FileProvider.getUriForFile( this, @@ -1458,12 +1491,12 @@ class WeeklyReportActivity : LocalizedActivity() { } @Suppress("DEPRECATION") - private fun saveWeeklyPng(file: File) { + private fun saveWeeklyPng(file: File, periodStart: LocalDate) { pendingPng = file val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("image/png") - .putExtra(Intent.EXTRA_TITLE, file.name) + .putExtra(Intent.EXTRA_TITLE, WeeklyPngPendingState.exportName(periodStart)) startActivityForResult(intent, REQUEST_EXPORT_WEEKLY_PNG) } @Deprecated("Uses the platform document picker without an AndroidX dependency") diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt index 75bd6a2..02247eb 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt @@ -5,6 +5,7 @@ import dev.gf2log.app.WeeklyPngPendingState import java.time.LocalDate import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -17,17 +18,22 @@ class WeeklyShareProjectionTest { @Test fun pendingWeeklyPngRestoresOnlyFromTheBoundedShareCache() { val cache = temporary.newFolder("cache") - val shared = WeeklyPngPendingState.directory(cache).apply { mkdirs() } - val published = java.io.File(shared, "GF2logger-week-20260809.png").apply { + WeeklyPngPendingState.directory(cache).mkdirs() + val periodStart = LocalDate.of(2026, 8, 9) + val published = WeeklyPngPendingState.newRenderTarget(cache, periodStart).apply { writeBytes(byteArrayOf(1, 2, 3)) } + val nextRender = WeeklyPngPendingState.newRenderTarget(cache, periodStart) val savedName = WeeklyPngPendingState.nameForState(cache, published) + assertNotEquals(published.name, nextRender.name) + assertEquals("GF2logger-week-20260809.png", WeeklyPngPendingState.exportName(periodStart)) assertEquals(published.canonicalFile, WeeklyPngPendingState.restore(cache, savedName)) assertEquals(null, WeeklyPngPendingState.restore(cache, "../outside.png")) - assertEquals(null, WeeklyPngPendingState.restore(cache, "GF2logger-week-20260810.png")) - val outside = temporary.newFile("GF2logger-week-20260811.png") + assertEquals(null, WeeklyPngPendingState.restore(cache, "GF2logger-week-20260809.png")) + assertEquals(null, WeeklyPngPendingState.restore(cache, nextRender.name)) + val outside = temporary.newFile(published.name) assertEquals(null, WeeklyPngPendingState.nameForState(cache, outside)) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c63e5f1..6665300 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -231,8 +231,11 @@ a one-level rollback cannot silently overwrite newer work. Weekly PNG export builds a privacy projection rather than screenshotting the Activity. Names are included by default; UIDs and private notes are opt-in. Rendering caps rows, dimensions, pixels, and note length. A document-picker save -persists only the validated cache filename across activity recreation, and Android -shares only a generated cache file through a non-exported `FileProvider`. Optional +persists only the validated cache filename across activity recreation. Every +render receives a fresh UUID-backed cache identity, while stale `FileProvider` +grants are revoked before their files are removed; the document picker still +receives a stable human-facing filename. Android shares only that generated +cache file through a non-exported `FileProvider`. Optional Discord delivery accepts only canonical HTTPS `discord.com` incoming-webhook URLs, stores the secret with Android Keystore AES-GCM, sends only the validated CSV body after confirmation, refuses redirects, and bounds bytes and timeouts.