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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

All notable changes to mobileGF2logger are documented here.

## Unreleased
## 2.2.0 - 2026-08-11

### Added

Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -800,13 +802,71 @@ class PlatoonBackupManagerIntegrationTest {
}
@RunWith(AndroidJUnit4::class)
class WeeklyReportActivityStateTest {
@Test
fun repeatedWeeklyPngRendersUseDifferentProviderUris() {
val context = ApplicationProvider.getApplicationContext<Context>()
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<Context>()
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
}
Expand Down
51 changes: 42 additions & 9 deletions app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}

Expand Down
7 changes: 5 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading