Prepare Android release artifacts and listing (#112)
Android ARM verification / rust (push) Failing after 49m41s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 22s

Add Rust distribution auditing, pinned post-build AAB signing, complete offline third-party notices, an Android privacy policy and reviewable Play listing assets. Preserve the application ID and audit both ARM ABIs, native symbols, baseline profiles and R8 mapping.

Fix dialog focus ownership, navigation-triggered cleanup loops and stale authentication errors after first-run setup, with native and real-activity regressions.

Google Play publication is deferred by the user. Issue #112 remains open for production signing, Console declarations and actual Play install/update verification.
This commit is contained in:
hugo committed 2026-10-15 16:16:01 +02:00
1 parent b54636b7bc
commit bf83a0ba41
51 files changed
+1493 -41

No files matched your search

+44 -2
View File
@@ -1,3 +1,6 @@
import groovy.json.JsonOutput
import java.util.Properties
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -7,6 +10,40 @@ plugins {
alias(libs.plugins.ksp)
}
val releaseVersion = Properties().apply {
rootProject.file("release.properties").inputStream().use(::load)
}
val generateThirdPartyNotices = tasks.register<Exec>("generateThirdPartyNotices") {
val inventory = layout.buildDirectory.file("reports/license-artifacts.json")
val output = layout.buildDirectory.file("generated/notices/assets/third-party-notices.txt")
outputs.file(output)
outputs.upToDateWhen { false }
workingDir = rootProject.projectDir.parentFile
doFirst {
val rows = configurations.getByName("releaseRuntimeClasspath").incoming.artifactView {
componentFilter { it is ModuleComponentIdentifier }
}.artifacts.artifacts.map { artifact ->
val id = artifact.id.componentIdentifier as ModuleComponentIdentifier
mapOf(
"component" to "${id.group}:${id.module}:${id.version}",
"path" to artifact.file.absolutePath
)
}
inventory.get().asFile.apply {
parentFile.mkdirs()
writeText(JsonOutput.toJson(rows))
}
commandLine(
"python3",
".gitea/scripts/android_notices.py",
inventory.get().asFile,
output.get().asFile
)
}
notCompatibleWithConfigurationCache("Resolves release license files under the project lock")
}
android {
namespace = "de.rfc1437.ironstorage"
compileSdk = libs.versions.compileSdk.get().toInt()
@@ -15,8 +52,8 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
applicationId = "de.rfc1437.ironstorage"
targetSdk = libs.versions.compileSdk.get().toInt()
versionCode = 1
versionName = "1.0.1"
versionCode = releaseVersion.getProperty("versionCode").toInt()
versionName = releaseVersion.getProperty("versionName")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}
@@ -25,6 +62,9 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
sourceSets.getByName(
"main"
).assets.srcDir(layout.buildDirectory.dir("generated/notices/assets").get().asFile)
buildTypes {
debug { isDebuggable = true }
release {
@@ -61,6 +101,8 @@ android {
}
}
tasks.named("preBuild") { dependsOn(generateThirdPartyNotices) }
kotlin {
jvmToolchain(25)
compilerOptions {
@@ -73,8 +73,8 @@ class AuthenticationLeaseTest {
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val shell = NativeFixtureShellRepository(session, scope)
val setup = NativeOnboardingRepository(session, scope, shell)
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val setup = NativeOnboardingRepository(session, scope, shell, authentication)
val password = "public-test-passphrase"
suspend fun settled() = withTimeout(180000) {
authentication.state.first { !it.busy }
@@ -22,12 +22,16 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.features.ProtectedContent
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@@ -50,6 +54,69 @@ class PresentationSecurityTest {
compose.enableAccessibilityChecks()
}
@Test fun leavingProtectedDestinationsRevealsTheCleanForeground() {
val viewModel = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
compose.waitUntil(10000) {
!viewModel.onboarding.setup.value.busy &&
viewModel.snapshot.value.state != ShellState.Loading
}
assumeTrue(
"Requires a store created through the retained emulator's normal setup UI",
viewModel.onboarding.setup.value.configured ||
viewModel.snapshot.value.state in listOf(ShellState.Ready, ShellState.Locked)
)
for (tab in listOf("Home", "Passwords", "Totp", "Home", "Passwords", "Preferences")) {
compose.onNodeWithTag("shell-tab-$tab").performClick()
if (tab == "Passwords") compose.onNodeWithText("New entry").assertIsDisplayed()
compose.waitForIdle()
compose.runOnIdle {
assertTrue(compose.activity.window.decorView.hasWindowFocus())
assertFalse(
"$tab must not remain behind the protective cover",
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
}
}
@Test fun stoppedProtectedDestinationsStayCoveredUntilResumed() {
val viewModel = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
compose.waitUntil(10000) {
!viewModel.onboarding.setup.value.busy &&
viewModel.snapshot.value.state != ShellState.Loading
}
assumeTrue(
"Requires a store created through the retained emulator's normal setup UI",
viewModel.onboarding.setup.value.configured ||
viewModel.snapshot.value.state in listOf(ShellState.Ready, ShellState.Locked)
)
for (tab in listOf("Passwords", "Totp")) {
compose.onNodeWithTag("shell-tab-$tab").performClick()
if (tab == "Passwords") compose.onNodeWithText("New entry").assertIsDisplayed()
val presentation = compose.activity.authentication.presentation
val generation = presentation.generation.value
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.onActivity {
assertTrue(presentation.generation.value > generation)
assertEquals(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
it.window.decorView.importantForAccessibility
)
}
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.waitForIdle()
compose.runOnIdle {
assertTrue(compose.activity.window.decorView.hasWindowFocus())
assertFalse(
"$tab must reveal its cleaned surface after resume",
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
}
}
@Test fun foregroundCleanupUnmasksOnlyAfterTheCleanGenerationIsComposed() {
val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
val dirty = mutableStateOf(true)
@@ -4,6 +4,7 @@ import android.os.Bundle
import android.os.Parcel
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
@@ -22,6 +23,7 @@ import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
@@ -55,6 +57,44 @@ class SettingsScreenTest {
compose.enableAccessibilityChecks()
}
@Test fun publicDocumentsRetainTheRealActivityFocusOwnership() {
val authentication = ViewModelProvider(
compose.activity
)[ShellViewModel::class.java].authentication
compose.onNodeWithTag("shell-tab-Preferences").performClick()
compose.onNodeWithTag("settings-search").performTextReplacement("privacy")
for ((button, content) in listOf(
"Privacy policy" to "IronStorage for Android",
"Third-party notices" to "IronStorage — Third-party notices"
)) {
val generation = authentication.presentation.generation.value
compose.onNodeWithTag("settings-list").performScrollToNode(hasText(button))
compose.onNodeWithText(button).performClick()
compose.waitUntil(5000) {
compose.onAllNodes(
hasText(content, substring = true)
).fetchSemanticsNodes().isNotEmpty()
}
compose.runOnIdle {
assertTrue(authentication.presentation.hasOwnedWindow)
assertEquals(generation, authentication.presentation.generation.value)
}
compose.onNodeWithText("Done").performClick()
compose.runOnIdle { assertFalse(authentication.presentation.hasOwnedWindow) }
compose.onNodeWithTag("settings-search").assertTextContains("privacy")
}
compose.onNodeWithTag("settings-list").performScrollToNode(hasText("Privacy policy"))
compose.onNodeWithText("Privacy policy").performClick()
val generation = authentication.presentation.generation.value
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.runOnIdle {
assertTrue(authentication.presentation.generation.value > generation)
assertFalse(authentication.presentation.hasOwnedWindow)
}
compose.onNodeWithTag("settings-search").assert(hasText("privacy").not())
}
@Test fun searchableSettingsKeepEditsAndUseExplicitSecretAndProviderActionsAtLargeFont() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
@@ -125,8 +165,28 @@ class SettingsScreenTest {
)[0].assertIsDisplayed()
assertFalse(repository.state.value.page!!.autofill.enabled)
search("privacy")
button("Privacy policy")
compose.waitUntil(5000) {
compose.onAllNodes(
hasText("IronStorage for Android", substring = true)
).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("IronStorage for Android", substring = true)
.assertTextContains(
"Android cloud backup and device-transfer backup are disabled",
substring = true
)
compose.onNodeWithText("Done").performClick()
button("MIT license")
compose.onNodeWithText("Done").assertIsDisplayed().performClick()
button("Third-party notices")
compose.waitUntil(5000) {
compose.onAllNodes(
hasText("IronStorage — Third-party notices")
).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Android release libraries").assertIsDisplayed()
compose.onNodeWithText("Done").performClick()
assertTrue(repository.reloads > 0)
search("GPG")
button("Import or export GPG QR codes")
@@ -161,11 +161,13 @@ object ShellModule {
@Singleton
fun onboardingRepository(
session: NativePrivateSession,
shell: ShellRepository
shell: ShellRepository,
authentication: AuthenticationRepository
): OnboardingRepository = NativeOnboardingRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
shell
shell,
authentication
)
@Provides