Prepare Android release artifacts and listing (#112)
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:
1 parent
b54636b7bc
commit
bf83a0ba41
51 files changed
+1493
-41
No files matched your search
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user