diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index d416eb1b6..b701462b4 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -465,6 +465,9 @@ jobs:
echo "File renamed successfully."
+ - name: Rename R8 Mapping
+ run: cp "$GITHUB_WORKSPACE/android/build/outputs/mapping/release/mapping.txt" "$GITHUB_WORKSPACE/android/build/outputs/apk/release/hammer-apk-mapping.txt"
+
- name: Create Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
env:
@@ -474,7 +477,7 @@ jobs:
prerelease: true
artifactErrorsFailBuild: true
allowUpdates: true
- artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk"
+ artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk,${{ github.workspace }}/android/build/outputs/apk/release/hammer-apk-mapping.txt"
snap:
needs: tests
diff --git a/.github/workflows/publish-google-play.yml b/.github/workflows/publish-google-play.yml
index 6d01db373..ac51a276a 100644
--- a/.github/workflows/publish-google-play.yml
+++ b/.github/workflows/publish-google-play.yml
@@ -89,3 +89,15 @@ jobs:
esac
echo "Running fastlane $LANE for track=$TRACK"
bundle exec fastlane "$LANE"
+
+ # Keeps the mappings for the exact Play builds next to the release, so prod
+ # crash traces can be retraced without the Play Console.
+ - name: Attach R8 mappings to the GitHub release
+ if: github.event_name == 'release'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ github.event.release.tag_name }}
+ run: |
+ cp android/build/outputs/mapping/release/mapping.txt hammer-google-play-mapping.txt
+ cp wear/build/outputs/mapping/release/mapping.txt hammer-wear-mapping.txt
+ gh release upload "$TAG" hammer-google-play-mapping.txt hammer-wear-mapping.txt --clobber
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index b98ca1ec5..75b102dab 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -11,6 +11,8 @@ jobs:
publish-google-play:
if: ${{ !contains(github.event.release.tag_name, '+') || contains(github.event.release.tag_name, '+google-play') }}
uses: ./.github/workflows/publish-google-play.yml
+ permissions:
+ contents: write
secrets: inherit
with:
track: production
diff --git a/android/src/main/res/xml/backup_rules.xml b/android/src/main/res/xml/backup_rules.xml
index 3541c377c..8df45f7c2 100644
--- a/android/src/main/res/xml/backup_rules.xml
+++ b/android/src/main/res/xml/backup_rules.xml
@@ -3,4 +3,5 @@
+
diff --git a/android/src/main/res/xml/data_extraction_rules.xml b/android/src/main/res/xml/data_extraction_rules.xml
index 5835bbc34..26160975f 100644
--- a/android/src/main/res/xml/data_extraction_rules.xml
+++ b/android/src/main/res/xml/data_extraction_rules.xml
@@ -4,10 +4,12 @@
+
+
diff --git a/common/src/androidHostTest/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStoreTest.kt b/common/src/androidHostTest/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStoreTest.kt
new file mode 100644
index 000000000..58c96f1a7
--- /dev/null
+++ b/common/src/androidHostTest/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStoreTest.kt
@@ -0,0 +1,150 @@
+package com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource
+
+import android.content.SharedPreferences
+import kotlinx.serialization.json.Json
+import javax.crypto.AEADBadTagException
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class EncryptedSharedPrefsAuthTokenStoreTest {
+
+ private val disk = FakeEncryptedPrefsDisk()
+
+ private fun createStore() = EncryptedSharedPrefsAuthTokenStore(
+ json = Json,
+ openPrefs = disk::open,
+ resetPrefs = disk::reset,
+ )
+
+ @Test
+ fun `tokens round trip`() {
+ val store = createStore()
+
+ store.put(URL, USER_ID, TOKENS)
+
+ assertEquals(TOKENS, createStore().get(URL, USER_ID))
+ }
+
+ @Test
+ fun `keyset the master key cannot decrypt is treated as no tokens`() {
+ disk.keysetDecryptable = false
+ val store = createStore()
+
+ val tokens = store.get(URL, USER_ID)
+
+ assertNull(tokens)
+ }
+
+ @Test
+ fun `store is usable again after an undecryptable keyset`() {
+ disk.keysetDecryptable = false
+ createStore().get(URL, USER_ID)
+
+ createStore().put(URL, USER_ID, TOKENS)
+
+ assertEquals(TOKENS, createStore().get(URL, USER_ID))
+ }
+
+ @Test
+ fun `value that cannot be decrypted is treated as no tokens`() {
+ createStore().put(URL, USER_ID, TOKENS)
+ disk.valuesDecryptable = false
+
+ val tokens = createStore().get(URL, USER_ID)
+
+ assertNull(tokens)
+ }
+
+ @Test
+ fun `put replaces a value that cannot be decrypted`() {
+ createStore().put(URL, USER_ID, TOKENS)
+ disk.valuesDecryptable = false
+ val replacement = AuthTokens(bearerToken = "new-bearer", refreshToken = "new-refresh")
+
+ createStore().put(URL, USER_ID, replacement)
+
+ assertEquals(replacement, createStore().get(URL, USER_ID))
+ }
+
+ companion object {
+ private const val URL = "https://hammer.example"
+ private const val USER_ID = 7L
+ private val TOKENS = AuthTokens(bearerToken = "bearer", refreshToken = "refresh")
+ }
+}
+
+/**
+ * Models the encrypted prefs file: [open] fails like EncryptedSharedPreferences.create
+ * when the Keystore master key cannot decrypt the stored keyset, and [reset] removes
+ * the file (keyset included) so the next open generates a fresh one.
+ */
+private class FakeEncryptedPrefsDisk {
+ val values = mutableMapOf()
+ var keysetDecryptable = true
+ var valuesDecryptable = true
+
+ fun open(): SharedPreferences {
+ if (!keysetDecryptable) throw AEADBadTagException()
+ return FakePrefs()
+ }
+
+ fun reset() {
+ values.clear()
+ keysetDecryptable = true
+ valuesDecryptable = true
+ }
+
+ private inner class FakePrefs : SharedPreferences {
+ override fun getString(key: String, defValue: String?): String? {
+ val stored = values[key] ?: return defValue
+ if (!valuesDecryptable) throw SecurityException("Could not decrypt value")
+ return stored
+ }
+
+ override fun contains(key: String): Boolean = key in values
+ override fun getAll(): MutableMap = values.toMutableMap()
+ override fun edit(): SharedPreferences.Editor = FakeEditor()
+
+ override fun getStringSet(key: String, defValues: MutableSet?) = unsupported()
+ override fun getInt(key: String, defValue: Int): Int = unsupported()
+ override fun getLong(key: String, defValue: Long): Long = unsupported()
+ override fun getFloat(key: String, defValue: Float): Float = unsupported()
+ override fun getBoolean(key: String, defValue: Boolean): Boolean = unsupported()
+ override fun registerOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener
+ ) = unsupported()
+
+ override fun unregisterOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener
+ ) = unsupported()
+ }
+
+ private inner class FakeEditor : SharedPreferences.Editor {
+ private val pending = mutableMapOf()
+ private var clearAll = false
+
+ override fun putString(key: String, value: String?) = apply { pending[key] = value }
+ override fun remove(key: String) = apply { pending[key] = null }
+ override fun clear() = apply { clearAll = true }
+
+ override fun commit(): Boolean {
+ if (clearAll) values.clear()
+ pending.forEach { (key, value) -> if (value == null) values.remove(key) else values[key] = value }
+ valuesDecryptable = true
+ return true
+ }
+
+ override fun apply() {
+ commit()
+ }
+
+ override fun putStringSet(key: String, values: MutableSet?) = unsupported()
+ override fun putInt(key: String, value: Int) = unsupported()
+ override fun putLong(key: String, value: Long) = unsupported()
+ override fun putFloat(key: String, value: Float) = unsupported()
+ override fun putBoolean(key: String, value: Boolean) = unsupported()
+ }
+}
+
+private fun unsupported(): Nothing = throw UnsupportedOperationException()
diff --git a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt
index bdfb5e2d9..18407af83 100644
--- a/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt
+++ b/common/src/androidMain/kotlin/com/darkrockstudios/apps/hammer/common/data/globalsettings/datasource/EncryptedSharedPrefsAuthTokenStore.kt
@@ -9,31 +9,46 @@ import io.github.aakira.napier.Napier
import kotlinx.atomicfu.locks.reentrantLock
import kotlinx.atomicfu.locks.withLock
import kotlinx.serialization.json.Json
+import java.io.IOException
+import java.security.GeneralSecurityException
+import java.security.KeyStore
/**
* [AuthTokenStore] backed by [EncryptedSharedPreferences] with an Android
* Keystore-backed master key. The account-keyed token map is stored as JSON in a
* single encrypted preferences entry.
*/
-class EncryptedSharedPrefsAuthTokenStore(
- context: Context,
+class EncryptedSharedPrefsAuthTokenStore internal constructor(
private val json: Json,
+ private val openPrefs: () -> SharedPreferences,
+ private val resetPrefs: () -> Unit,
) : AuthTokenStore {
+ constructor(context: Context, json: Json) : this(
+ json = json,
+ openPrefs = { openEncryptedPrefs(context) },
+ resetPrefs = { resetEncryptedPrefs(context) },
+ )
+
private val lock = reentrantLock()
private val prefs: SharedPreferences by lazy {
- val masterKey = MasterKey.Builder(context)
- .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
- .build()
+ try {
+ openPrefs()
+ } catch (e: GeneralSecurityException) {
+ recreatePrefs(e)
+ } catch (e: IOException) {
+ recreatePrefs(e)
+ }
+ }
- EncryptedSharedPreferences.create(
- context,
- PREFS_NAME,
- masterKey,
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
- )
+ // The keyset lives in the prefs file but its master key lives in the Keystore, which is
+ // never backed up, so a restored or orphaned file is undecryptable, and some keystores
+ // leave the master key itself unusable. Start over with both (forces re-login).
+ private fun recreatePrefs(cause: Exception): SharedPreferences {
+ Napier.w("Auth token keyset is unreadable; recreating the store", cause)
+ resetPrefs()
+ return openPrefs()
}
override fun get(url: String, userId: Long): AuthTokens? = lock.withLock {
@@ -54,11 +69,11 @@ class EncryptedSharedPrefsAuthTokenStore(
}
private fun loadMap(): Map {
- val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap()
return try {
+ val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap()
json.decodeFromString