diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-05 15:52:37 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-05 15:52:37 +0100 |
| commit | 6195060f35c78249f4630865e09855f9e624bd32 (patch) | |
| tree | 0611f94925e6768621cace46c7b97886e7dc8469 /app | |
| parent | daa7ee010aa88df8664c9b10eb09bbe95eb11bd9 (diff) | |
| download | HardenedPass-6195060f35c78249f4630865e09855f9e624bd32.tar.gz HardenedPass-6195060f35c78249f4630865e09855f9e624bd32.zip | |
feat(app): add an about screen
Version with build number, documentation and project links, Ko-fi, the privacy
statement, the certificate this copy was really signed with, and the licenses.
Debug builds also show the git commit, since a test build can share its version
with a release.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app')
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt | 208 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt | 7 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt | 10 | ||||
| -rw-r--r-- | app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp | bin | 0 -> 4100 bytes | |||
| -rw-r--r-- | app/src/main/res/values-it/strings.xml | 15 | ||||
| -rw-r--r-- | app/src/main/res/values/strings.xml | 15 | ||||
| -rw-r--r-- | app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt | 39 |
7 files changed, 293 insertions, 1 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt new file mode 100644 index 00000000..fd47c119 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.util.extensions.getPackageInfoCompat +import app.passwordstore.util.extensions.snackbar +import java.security.MessageDigest + +private const val REPOSITORY = "https://github.com/danixland/HardenedPass" +private const val DOCUMENTATION = "https://danix.xyz/hardenedpass" +private const val KO_FI = "https://ko-fi.com/K6Y520C9I6" + +/** + * What ships in the APK and its license, by hand: update it when a dependency comes or goes. A + * generating plugin would be one more build dependency for a list this short. + */ +private val LICENSES = + listOf( + "AndroidX, Jetpack Compose" to "Apache-2.0", + "Apache Commons Codec, Curtains, Guava, JavaEWAH, JSpecify, uri-kmp" to "Apache-2.0", + "Bouncy Castle" to "Bouncy Castle Licence (MIT)", + "Dagger, Hilt" to "Apache-2.0", + "EFF Diceware word list" to "CC-BY", + "JGit" to "EDL-1.0 (BSD-3-Clause)", + "kotlin-result" to "ISC", + "Kotlin, kotlinx" to "Apache-2.0", + "LeakCanary plumber, logcat" to "Apache-2.0", + "Manrope" to "OFL-1.1", + "PGPainless" to "Apache-2.0", + "Public Suffix List" to "MPL-2.0", + "SLF4J" to "MIT", + "sshj" to "Apache-2.0", + "sublime-fuzzy" to "MIT", + "ZXing, zxing-android-embedded" to "Apache-2.0", + ) + +/** `0.3.0 (300)`, followed by the [commit] on the debug builds that have one. */ +internal fun versionLine(name: String, code: Int, commit: String): String = + if (commit.isEmpty()) "$name ($code)" else "$name ($code) · $commit" + +/** The changelog at the tag of [versionName], without a suffix like `-debug`. */ +internal fun changelogUrl(versionName: String): String = + "$REPOSITORY/blob/v${versionName.substringBefore('-')}/CHANGELOG.md" + +/** SHA-256 of [certificate] as `apksigner` and `keytool` users compare it, colon separated. */ +internal fun certificateFingerprint(certificate: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(certificate).joinToString(":") { "%02X".format(it) } + +/** The certificate this copy was really signed with, so it can be compared with the published. */ +private fun Context.signingFingerprint(): String? = + packageManager + .getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + .signingInfo + ?.apkContentsSigners + ?.firstOrNull() + ?.let { certificateFingerprint(it.toByteArray()) } + +/** The about screen. Links leave for the browser: the app itself makes no connection for them. */ +@Composable +fun AboutDestination( + activity: FragmentActivity, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val fingerprint = remember { activity.signingFingerprint() } + AboutScreen( + version = + versionLine(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, BuildConfig.GIT_COMMIT), + changelog = changelogUrl(BuildConfig.VERSION_NAME), + fingerprint = fingerprint, + onCopy = { activity.copyTextToClipboard(it) }, + onOpen = { url -> + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (_: ActivityNotFoundException) { + activity.snackbar(message = activity.getString(R.string.about_no_browser)) + } + }, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun AboutScreen( + version: String, + changelog: String, + fingerprint: String?, + onCopy: (String) -> Unit, + onOpen: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsScaffold(stringResource(R.string.about), onBack, modifier) { + Column( + Modifier.fillMaxWidth().padding(SpacingMedium), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + Box(Modifier.size(96.dp).clip(CircleShape)) { + Image(painterResource(R.drawable.ic_launcher_background), contentDescription = null) + Image(painterResource(R.drawable.ic_launcher_foreground), contentDescription = null) + } + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.about_summary), textAlign = TextAlign.Center) + } + AboutRow(stringResource(R.string.about_version), version) { onCopy(version) } + AboutRow(stringResource(R.string.about_docs)) { onOpen(DOCUMENTATION) } + AboutRow(stringResource(R.string.about_changelog)) { onOpen(changelog) } + AboutRow(stringResource(R.string.about_source)) { onOpen(REPOSITORY) } + AboutRow(stringResource(R.string.about_issues)) { onOpen("$REPOSITORY/issues") } + // White in both themes: the logo's dark outline disappears on a dark background, and Ko-fi + // asks that the logo itself stays as it is. + Button( + onClick = { onOpen(KO_FI) }, + modifier = Modifier.fillMaxWidth().padding(SpacingMedium), + colors = + ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Image( + painterResource(R.drawable.ic_kofi_symbol), + contentDescription = null, + modifier = Modifier.height(24.dp), + ) + Box(Modifier.width(SpacingMedium)) + Text(stringResource(R.string.about_support)) + } + ListItem( + headlineContent = { Text(stringResource(R.string.about_privacy_title)) }, + supportingContent = { Text(stringResource(R.string.about_privacy)) }, + colors = rowColors(), + ) + if (fingerprint != null) + ListItem( + headlineContent = { Text(stringResource(R.string.about_certificate)) }, + supportingContent = { Text(fingerprint, fontFamily = FontFamily.Monospace) }, + modifier = Modifier.clickable(role = Role.Button) { onCopy(fingerprint) }, + colors = rowColors(), + ) + AboutRow(stringResource(R.string.about_license), stringResource(R.string.about_license_text)) { + onOpen("$REPOSITORY/blob/master/LICENSE.txt") + } + ListItem( + headlineContent = { Text(stringResource(R.string.about_libraries)) }, + colors = rowColors(), + ) + LICENSES.forEach { (name, license) -> + ListItem( + headlineContent = { Text(name) }, + supportingContent = { Text(license) }, + colors = rowColors(), + ) + } + } +} + +@Composable +private fun AboutRow(title: String, summary: String? = null, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = summary?.let { { Text(it) } }, + modifier = Modifier.clickable(role = Role.Button, onClick = onClick), + colors = rowColors(), + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt index ae4e277b..44ae06d8 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt @@ -30,6 +30,9 @@ import kotlinx.serialization.Serializable /** The store's commit log. */ @Serializable data object GitLogRoute +/** Version, links, certificate and licenses. */ +@Serializable data object AboutRoute + /** HTTP(S) proxy settings. */ @Serializable data object ProxyRoute @@ -44,9 +47,13 @@ fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController composable<SettingsRoute> { entry -> SettingsScreen( onSection = { if (onTop(entry)) nav.navigate(SettingsSectionRoute(it)) }, + onAbout = { if (onTop(entry)) nav.navigate(AboutRoute) }, onBack = { close(entry) }, ) } + composable<AboutRoute> { entry -> + AboutDestination(activity = activity, onBack = { close(entry) }) + } composable<SettingsSectionRoute> { entry -> SettingsSectionDestination( activity = activity, diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt index d13d1cf5..14895017 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -68,7 +69,7 @@ enum class SettingsAction { /** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SettingsScaffold( +internal fun SettingsScaffold( title: String, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -97,6 +98,7 @@ private fun SettingsScaffold( @Composable fun SettingsScreen( onSection: (SettingsSection) -> Unit, + onAbout: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -109,6 +111,12 @@ fun SettingsScreen( colors = rowColors(), ) } + ListItem( + headlineContent = { Text(stringResource(R.string.about)) }, + modifier = Modifier.clickable(role = Role.Button, onClick = onAbout), + leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) }, + colors = rowColors(), + ) } } diff --git a/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp Binary files differnew file mode 100644 index 00000000..f31847d8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e15b903f..f6de40bd 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -379,4 +379,19 @@ <string name="git_reset_confirm_message">I commit e le modifiche locali che non sono su questo remote branch vengono scartati.</string> <string name="git_tool_done">%1$s: completato</string> <string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string> + <string name="about">Informazioni</string> + <string name="about_summary">Un client Android per pass, il gestore di password standard di Unix. Un fork mantenuto di Android Password Store.</string> + <string name="about_version">Versione</string> + <string name="about_docs">Documentazione</string> + <string name="about_changelog">Novità</string> + <string name="about_source">Codice sorgente</string> + <string name="about_issues">Segnala un problema</string> + <string name="about_support">Sostieni su Ko-fi</string> + <string name="about_privacy_title">Privacy</string> + <string name="about_privacy">Nessuna telemetria, nessuna analisi, nessun servizio Google. HardenedPass si collega solo al tuo server git, tramite il tuo proxy se ne imposti uno.</string> + <string name="about_certificate">Certificato di firma (SHA-256)</string> + <string name="about_license">Licenza</string> + <string name="about_license_text">GNU General Public License v3.0 only. Copyright degli autori di Android Password Store e dei contributori di HardenedPass.</string> + <string name="about_libraries">Licenze open source</string> + <string name="about_no_browser">Nessun browser trovato per aprire questo link</string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 755fa9bc..38ef2cdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -413,4 +413,19 @@ <string name="git_reset_confirm_message">Local commits and changes that are not on this remote branch are discarded.</string> <string name="git_tool_done">%1$s: done</string> <string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string> + <string name="about">About</string> + <string name="about_summary">An Android client for pass, the standard Unix password manager. A maintained fork of Android Password Store.</string> + <string name="about_version">Version</string> + <string name="about_docs">Documentation</string> + <string name="about_changelog">What\'s new</string> + <string name="about_source">Source code</string> + <string name="about_issues">Report a problem</string> + <string name="about_support">Support on Ko-fi</string> + <string name="about_privacy_title">Privacy</string> + <string name="about_privacy">No telemetry, no analytics, no Google services. HardenedPass connects only to your git server, through your proxy if you set one.</string> + <string name="about_certificate">Signing certificate (SHA-256)</string> + <string name="about_license">License</string> + <string name="about_license_text">GNU General Public License v3.0 only. Copyright the Android Password Store authors and HardenedPass contributors.</string> + <string name="about_libraries">Open source licenses</string> + <string name="about_no_browser">No browser found to open this link</string> </resources> diff --git a/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt new file mode 100644 index 00000000..e692c229 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AboutTest { + + @Test + fun versionLineWithoutCommit() { + assertEquals("0.3.0 (300)", versionLine("0.3.0", 300, "")) + } + + @Test + fun versionLineWithCommit() { + assertEquals("0.3.0-debug (300) · daa7ee01", versionLine("0.3.0-debug", 300, "daa7ee01")) + } + + @Test + fun changelogUrlPointsAtTheReleaseTag() { + assertEquals( + "https://github.com/danixland/HardenedPass/blob/v0.3.0/CHANGELOG.md", + changelogUrl("0.3.0-debug"), + ) + } + + @Test + fun fingerprintIsColonSeparatedUppercaseSha256() { + assertEquals( + "BA:78:16:BF:8F:01:CF:EA:41:41:40:DE:5D:AE:22:23:" + + "B0:03:61:A3:96:17:7A:9C:B4:10:FF:61:F2:00:15:AD", + certificateFingerprint("abc".encodeToByteArray()), + ) + } +} |
