Implement Android Credential Manager and Autofill providers (#109)
Android ARM verification / rust (push) Failing after 49m12s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 21s

This commit is contained in:
hugo committed 2026-10-12 19:18:10 +02:00
1 parent 316a875d63
commit 0c8366d34f
48 files changed
+4138 -78

No files matched your search

+2
View File
@@ -70,6 +70,8 @@ kotlin {
}
dependencies {
implementation(libs.credentials)
implementation(libs.autofill)
implementation(project(":features"))
implementation(project(":core"))
implementation(project(":platform"))
+3 -2
View File
@@ -14,8 +14,8 @@ androidx.appcompat:appcompat-resources:1.2.0=benchmarkLintChecksClasspath,benchm
androidx.appcompat:appcompat:1.2.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-common:2.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-runtime:2.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.autofill:autofill:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.biometric:biometric:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.autofill:autofill:1.3.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.biometric:biometric:1.1.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.viewfinder:viewfinder-core:1.5.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-camera2-pipe:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
@@ -84,6 +84,7 @@ androidx.core:core-backported-fixes:1.0.0=benchmarkLintChecksClasspath,benchmark
androidx.core:core-ktx:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-viewtree:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.credentials:credentials:1.6.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.cursoradapter:cursoradapter:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview-poolingcontainer:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
@@ -0,0 +1,108 @@
package de.rfc1437.ironstorage
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import de.rfc1437.ironstorage.core.CredentialCandidate
import de.rfc1437.ironstorage.core.ProviderRequest
import de.rfc1437.ironstorage.features.CredentialSelectionScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class CredentialSelectionScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private var selected by mutableStateOf<String?>(null)
private var destination by mutableStateOf("")
private var consent by mutableStateOf(false)
private var overwrite by mutableStateOf(false)
private var query by mutableStateOf("")
private var passphrase by mutableStateOf("")
private var confirmed = 0
private fun display(saving: Boolean) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
CredentialSelectionScreen(
ProviderRequest(
"public-test-token",
"example.client",
"revision",
listOf(
CredentialCandidate("example.client/alice", true)
)
),
saving,
selected,
query,
destination,
passphrase,
consent,
overwrite,
false,
null,
onSelect = {
selected = it
consent = false
},
onQuery = { query = it },
onDestination = {
destination = it
consent = false
overwrite = false
},
onPassphrase = { passphrase = it },
onConsent = { consent = it },
onOverwrite = { overwrite = it },
onConfirm = { confirmed++ },
onCancel = {
consent = false
passphrase = ""
}
)
}
}
}
}
@Test fun fillRequiresSelectionAndNamedCallerConsent() {
display(false)
compose.onNodeWithText("Unlock and fill").assertIsNotEnabled()
compose.onNodeWithText("example.client/alice").performClick()
compose.onNodeWithText("Unlock and fill").assertIsNotEnabled()
compose.onNodeWithText(
"Allow example.client to receive this entry's username and password"
).performClick()
compose.onNodeWithText("Unlock and fill").assertIsEnabled().performClick()
assertEquals(1, confirmed)
compose.onNodeWithText("Choose another entry").performClick()
assertEquals(false, consent)
compose.onNodeWithText("Unlock and fill").assertIsNotEnabled()
}
@Test fun saveRequiresDestinationAndChangingItClearsConsents() {
display(true)
compose.onNodeWithText("Confirm save").assertIsNotEnabled()
compose.onNodeWithText("Destination entry path").performTextInput("chosen-entry")
compose.onNodeWithText("Allow updating this destination if it exists").performClick()
compose.onNodeWithText(
"Save the supplied credential at the chosen destination"
).performClick()
compose.onNodeWithText("Confirm save").assertIsEnabled()
compose.onNodeWithText("Destination entry path").performTextInput("-other")
compose.runOnIdle {
assertEquals(false, consent)
assertEquals(false, overwrite)
}
compose.onNodeWithText("Confirm save").assertIsNotEnabled()
compose.onNodeWithText("Cancel").performClick()
assertEquals(0, confirmed)
}
}
@@ -0,0 +1,51 @@
package de.rfc1437.ironstorage
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.EntryPoints
import de.rfc1437.ironstorage.core.CredentialCaller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assume.assumeTrue
import org.junit.Test
class ProviderFixtureSetupTest {
/** Opt-in only. Never replaces a configured store or key on the retained emulator. */
@Test fun seedEmptyEmulatorForComputerUse(): Unit = runBlocking {
assumeTrue(InstrumentationRegistry.getArguments().getString("providerFixture") == "true")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
val passphrase = "public-provider-fixture-passphrase"
val setup = access.onboarding()
withContext(Dispatchers.Main.immediate) { setup.refresh() }
val before = withTimeout(30000) { setup.setup.first { !it.busy } }
check(!before.configured && !before.existingClone && before.keys.isEmpty())
withContext(Dispatchers.Main.immediate) {
setup.generate("Provider emulator fixture", passphrase)
}
val generated = withTimeout(30000) {
setup.setup.first {
!it.busy &&
it.selectedKey.isNotEmpty()
}
}
withContext(Dispatchers.Main.immediate) {
setup.createLocal(generated.selectedKey, passphrase)
}
withTimeout(30000) { setup.setup.first { !it.busy && it.configured } }
// Exercise exactly the explicit storage save contract used by both OS services.
val credentials = access.credentials()
val caller = CredentialCaller("de.rfc1437.ironstorage.test", listOf("AA".repeat(32)), null)
val request = credentials.begin(caller)
credentials.stage(
request.token,
caller,
"fixture-alice",
"public-provider-fixture-password"
)
credentials.save(request.token, caller, "provider.example", true, false, passphrase)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
}
}
@@ -0,0 +1,108 @@
package de.rfc1437.ironstorage
import android.content.ComponentCallbacks2
import android.content.Intent
import android.os.Build
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.EntryPoints
import de.rfc1437.ironstorage.core.CredentialCaller
import de.rfc1437.ironstorage.providers.ProviderContext
import de.rfc1437.ironstorage.providers.ProviderRequests
import de.rfc1437.ironstorage.providers.installedCaller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
class ProviderIntentSecurityTest {
@Test
fun retainedSystemSaveContainsSubmittedFields(): Unit = runBlocking {
assumeTrue(
InstrumentationRegistry.getArguments().getString("providerSavedFixtureValidation") ==
"true"
)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val caller = context.installedCaller("de.rfc1437.ironstorage.providerclient")
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
for (path in listOf("sacrd", "afsave")) {
val request = access.credentials().begin(caller)
try {
val value = access.credentials().fill(
request.token,
caller,
path,
true,
"public-provider-fixture-passphrase",
emptyList()
)
assertEquals("fixture-bob", value.username)
assertEquals("public-provider-new-password", value.password)
} finally {
access.credentials().cancel(request.token)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
}
}
}
@Suppress("DEPRECATION")
@Test
fun retainedFixtureCallerCanPrepareMetadata(): Unit = runBlocking {
assumeTrue(
InstrumentationRegistry.getArguments().getString("providerFixtureValidation") == "true"
)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val caller = context.installedCaller("de.rfc1437.ironstorage.providerclient")
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
val request = access.credentials().begin(caller)
try {
assertTrue(request.candidates.any { it.path == "provider.example" })
withContext(Dispatchers.Main.immediate) {
val app = context.applicationContext as IronStorageApplication
app.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN)
app.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND)
}
assertTrue(access.credentials().request(request.token, caller).candidates.isNotEmpty())
withContext(Dispatchers.Main.immediate) {
(context.applicationContext as IronStorageApplication).onTrimMemory(
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW
)
}
assertTrue(
runCatching {
access.credentials().request(request.token, caller)
}.isFailure
)
} finally {
access.credentials().cancel(request.token)
}
}
@Test fun inlineAttributionCannotInvokeThePrivateAuthenticationActivity() {
assumeTrue(Build.VERSION.SDK_INT >= 31)
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertNull(ProviderRequests.get(Intent()))
val token = "public-pending-intent-fixture"
val caller = CredentialCaller("example.client", listOf("AA".repeat(32)), null)
try {
val authentication = ProviderRequests.put(
context,
ProviderContext(token, caller, "autofill"),
"fixture"
)
val attribution = ProviderRequests.attribution(context)
assertTrue(attribution.isImmutable)
assertFalse(authentication.isImmutable)
assertNotEquals(authentication, attribution)
} finally {
ProviderRequests.remove(token)
}
}
}
@@ -0,0 +1,16 @@
package de.rfc1437.ironstorage
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ProviderFixtureAccess {
fun onboarding(): OnboardingRepository
fun authentication(): NativeAuthenticationRepository
fun credentials(): NativeCredentialRepository
}
+17
View File
@@ -3,9 +3,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- OS-mediated clients need signer verification even without discoverable intent filters. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Credential Manager is a provider on API 34+; API 26–33 uses framework Autofill. -->
<application
tools:ignore="CredentialDependency"
android:name=".IronStorageApplication"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules"
@@ -15,6 +20,18 @@
android:supportsRtl="true"
android:theme="@style/Theme.IronStorage"
android:usesCleartextTraffic="false">
<service android:name=".providers.IronAutofillService" android:exported="true"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter><action android:name="android.service.autofill.AutofillService" /></intent-filter>
<meta-data android:name="android.autofill" android:resource="@xml/autofill_service" />
</service>
<service tools:targetApi="34" android:name=".providers.IronCredentialProviderService" android:exported="true" android:enabled="@bool/credential_provider_enabled"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE">
<intent-filter><action android:name="android.service.credentials.CredentialProviderService" /></intent-filter>
<meta-data android:name="android.credentials.provider" android:resource="@xml/credential_provider" />
</service>
<activity android:name=".providers.CredentialSelectionActivity" android:exported="false"
android:excludeFromRecents="true" android:taskAffinity="" android:windowSoftInputMode="adjustResize" />
<activity android:name=".MainActivity" android:exported="true"
android:windowSoftInputMode="adjustResize"
android:supportsPictureInPicture="false">
@@ -1,6 +1,7 @@
package de.rfc1437.ironstorage
import android.app.Application
import android.content.ComponentCallbacks2
import dagger.hilt.android.HiltAndroidApp
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
@@ -16,7 +17,12 @@ class IronStorageApplication : Application() {
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onTrimMemory(level: Int) {
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
// UI/cache transitions also occur while the system picker holds a non-secret request.
if (level < ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN ||
level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE
) {
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
}
super.onTrimMemory(level)
}
@@ -38,6 +38,13 @@ import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object ShellModule {
@Provides
@Singleton
fun credentials(
authentication: NativeAuthenticationRepository
): de.rfc1437.ironstorage.core.NativeCredentialRepository =
de.rfc1437.ironstorage.core.NativeCredentialRepository(authentication)
@Provides
@Singleton
fun androidSettingsPlatform(
@@ -0,0 +1,362 @@
package de.rfc1437.ironstorage.providers
import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.service.autofill.Dataset
import android.view.View
import android.view.WindowManager
import android.view.autofill.AutofillManager
import android.view.autofill.AutofillManager.EXTRA_AUTHENTICATION_RESULT_EPHEMERAL_DATASET
import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CreatePasswordResponse
import androidx.credentials.GetCredentialResponse
import androidx.credentials.GetPasswordOption
import androidx.credentials.PasswordCredential
import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import de.rfc1437.ironstorage.core.ProviderRequest
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@AndroidEntryPoint
class CredentialSelectionActivity : FragmentActivity() {
@Inject lateinit var credentials: NativeCredentialRepository
@Inject lateinit var authentication: AuthenticationRepository
@Inject lateinit var keystore: AndroidKeystoreAdapter
private var context: ProviderContext? = null
private var request by mutableStateOf<ProviderRequest?>(null)
private var selected by mutableStateOf<String?>(null)
private var query by mutableStateOf("")
private var destination by mutableStateOf("")
private var passphrase by mutableStateOf("")
private var consent by mutableStateOf(false)
private var overwrite by mutableStateOf(false)
private var busy by mutableStateOf(false)
private var failure by mutableStateOf<String?>(null)
private var delivered = false
private var searchSequence = 0
private var cleanup: AutoCloseable? = null
private val screenState = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_SCREEN_OFF) cancel()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
window.decorView.filterTouchesWhenObscured = true
cleanup = authentication.presentation.register {
passphrase = ""
if (!delivered) finish()
}
val screenFilter = IntentFilter(Intent.ACTION_SCREEN_OFF)
if (Build.VERSION.SDK_INT >=
33
) {
registerReceiver(screenState, screenFilter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(screenState, screenFilter)
}
if (Build.VERSION.SDK_INT >= 33) setRecentsScreenshotEnabled(false)
window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
if (Build.VERSION.SDK_INT >=
30
) {
window.decorView.importantForContentCapture =
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
}
setResult(RESULT_CANCELED)
keystore.attach(this)
context = ProviderRequests.get(intent)
selected = context?.selected
if (context == null || savedInstanceState != null ||
getSystemService(KeyguardManager::class.java).isDeviceLocked
) {
finish()
return
}
lifecycleScope.launch {
try {
val pending = requireNotNull(context)
when (pending.mode) {
"credential" -> {
if (Build.VERSION.SDK_INT < 34) error("Unsupported credential platform")
val supplied =
requireNotNull(
PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
)
require(
credentialCaller(supplied.callingAppInfo, credentials) == pending.caller
)
val options = supplied.credentialOptions
.filterIsInstance<GetPasswordOption>()
require(
options.size == 1 &&
options.single().allowedUserIds.toList().sorted() ==
pending.allowedUsernames.sorted()
)
}
"create" -> {
if (Build.VERSION.SDK_INT < 34) error("Unsupported credential platform")
val supplied =
requireNotNull(
PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
)
require(
credentialCaller(supplied.callingAppInfo, credentials) == pending.caller
)
val password = supplied.callingRequest as CreatePasswordRequest
credentials.stage(
pending.token,
pending.caller,
password.id,
password.password
)
}
"autofill" -> {
@Suppress("DEPRECATION")
val structure =
requireNotNull(
intent.getParcelableExtra<android.app.assist.AssistStructure>(
AutofillManager.EXTRA_ASSIST_STRUCTURE
)
)
val facts = autofillFacts(structure, credentials)
require(
facts.caller == pending.caller && facts.username == pending.username &&
facts.password == pending.password
)
}
"save" -> require(installedCaller(pending.caller.packageName) == pending.caller)
else -> error("Unsupported request")
}
// System requests can contain secrets. Retain only the bound metadata.
intent = Intent().setAction(intent.action).setData(intent.data)
request = credentials.request(pending.token, pending.caller)
} catch (_: Exception) {
cancel()
return@launch
}
while (!isFinishing) {
delay(1_000)
if (!busy) {
try {
credentials.request(pendingToken(), requireNotNull(context).caller, query)
} catch (
_: Exception
) {
cancel()
return@launch
}
}
}
}
setContent {
de.rfc1437.ironstorage.features.ProtectedContent(authentication.presentation) {
de.rfc1437.ironstorage.features.ApplicationTheme(authentication) {
de.rfc1437.ironstorage.features.CredentialSelectionScreen(
request, context?.mode in listOf("create", "save"), selected, query,
destination, passphrase, consent, overwrite, busy, failure,
onSelect = {
selected = it
consent = false
},
onQuery = {
query = it
search()
},
onDestination = {
destination = it
consent = false
overwrite = false
},
onPassphrase = { passphrase = it },
onConsent = { consent = it },
onOverwrite = { overwrite = it },
onConfirm = ::confirm,
onCancel = ::cancel
)
}
}
}
}
private fun pendingToken() = requireNotNull(context).token
private fun search() {
val sequence = ++searchSequence
lifecycleScope.launch {
delay(150)
if (sequence != searchSequence || busy) return@launch
try {
val result = credentials.request(
pendingToken(),
requireNotNull(context).caller,
query
)
if (sequence == searchSequence) request = result
} catch (_: Exception) {
if (sequence == searchSequence && !busy) cancel()
}
}
}
private fun confirm() {
if (busy) return
searchSequence++
busy = true
failure = null
val suppliedPassphrase = passphrase.takeIf { it.isNotEmpty() }
passphrase = ""
lifecycleScope.launch {
try {
val pending = requireNotNull(context)
check(!getSystemService(KeyguardManager::class.java).isDeviceLocked)
val caller = installedCaller(pending.caller.packageName)
.copy(origin = pending.caller.origin)
val output = Intent()
withTimeout(120_000) {
when (pending.mode) {
"create", "save" -> {
credentials.save(
pending.token,
caller,
destination,
consent,
overwrite,
suppliedPassphrase
)
if (pending.mode ==
"create"
) {
PendingIntentHandler.setCreateCredentialResponse(
output,
CreatePasswordResponse()
)
}
}
else -> {
val value = credentials.fill(
pending.token,
caller,
requireNotNull(selected),
consent,
suppliedPassphrase,
pending.allowedUsernames
)
if (pending.mode == "credential") {
PendingIntentHandler.setGetCredentialResponse(
output,
GetCredentialResponse(
PasswordCredential(value.username, value.password)
)
)
} else {
val display = RemoteViews(
packageName,
android.R.layout.simple_list_item_1
).apply {
setTextViewText(android.R.id.text1, requireNotNull(selected))
}
@Suppress("DEPRECATION")
val dataset = Dataset.Builder(display)
.setValue(
requireNotNull(pending.username),
AutofillValue.forText(value.username)
)
.setValue(
requireNotNull(pending.password),
AutofillValue.forText(value.password)
).build()
output.putExtra(
AutofillManager.EXTRA_AUTHENTICATION_RESULT,
dataset
)
if (Build.VERSION.SDK_INT >=
31
) {
output.putExtra(
EXTRA_AUTHENTICATION_RESULT_EPHEMERAL_DATASET,
true
)
}
}
}
}
}
delivered = true
ProviderRequests.remove(pending.token)
setResult(RESULT_OK, output)
finish()
} catch (_: Exception) {
failure = authentication.state.value.failure?.let { value ->
"${value.title}\n${value.detail}"
} ?: "The request could not be completed. Cancel and request again."
} finally {
busy = false
}
}
}
private fun cancel() {
authentication.cancel()
passphrase = ""
setResult(RESULT_CANCELED)
finish()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && !keystore.authenticationPending && !delivered) {
passphrase = ""
authentication.lifecycle(AuthenticationLifecycle.Background)
finish()
}
}
override fun onUserLeaveHint() {
if (!keystore.authenticationPending && !delivered) cancel()
super.onUserLeaveHint()
}
override fun onStop() {
if (!keystore.authenticationPending && !delivered) cancel()
super.onStop()
}
override fun onPause() {
passphrase = ""
if (!keystore.authenticationPending) {
authentication.lifecycle(AuthenticationLifecycle.Background)
if (!delivered) finish()
}
super.onPause()
}
override fun onDestroy() {
cleanup?.close()
unregisterReceiver(screenState)
passphrase = ""
context?.let { pending ->
ProviderRequests.remove(pending.token)
}
authentication.lock()
keystore.detach(this)
super.onDestroy()
}
}
@@ -0,0 +1,173 @@
package de.rfc1437.ironstorage.providers
import android.app.assist.AssistStructure
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.Dataset
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import android.service.autofill.InlinePresentation
import android.service.autofill.SaveCallback
import android.service.autofill.SaveInfo
import android.service.autofill.SaveRequest
import android.widget.RemoteViews
import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@AndroidEntryPoint
class IronAutofillService : AutofillService() {
@Inject lateinit var credentials: NativeCredentialRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@Suppress("DEPRECATION")
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
callback: FillCallback
) {
var token: String? = null
val job = scope.launch {
try {
val facts = autofillFacts(request.fillContexts.last().structure, credentials)
val result = withTimeout(5_000) { credentials.begin(facts.caller) }
token = result.token
if (cancellationSignal.isCanceled) {
credentials.cancel(result.token)
return@launch
}
val pending = ProviderRequests.put(
this@IronAutofillService,
ProviderContext(
result.token,
facts.caller,
"autofill",
username = facts.username,
password = facts.password
),
"autofill"
)
val presentation = RemoteViews(
packageName,
android.R.layout.simple_list_item_1
).apply {
setTextViewText(android.R.id.text1, "Unlock and search IronStorage")
}
@Suppress("DEPRECATION")
val dataset = Dataset.Builder(presentation)
.setValue(facts.username, null)
.setValue(facts.password, null)
.setAuthentication(pending.intentSender)
if (Build.VERSION.SDK_INT >= 30) {
val spec = request.inlineSuggestionsRequest
?.inlinePresentationSpecs?.firstOrNull()
if (spec != null &&
UiVersions.getVersions(spec.style).contains(UiVersions.INLINE_UI_VERSION_1)
) {
val content: UiVersions.Content = InlineSuggestionUi.newContentBuilder(
ProviderRequests.attribution(this@IronAutofillService)
).setTitle("IronStorage").setSubtitle("Unlock and search").build()
dataset.setInlinePresentation(
InlinePresentation(content.slice, spec, false)
)
}
}
val response = FillResponse.Builder().addDataset(dataset.build())
.setSaveInfo(
SaveInfo.Builder(
SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,
arrayOf(facts.username, facts.password)
).build()
)
.build()
callback.onSuccess(response)
} catch (_: Exception) {
if (!cancellationSignal.isCanceled) callback.onSuccess(null)
}
}
cancellationSignal.setOnCancelListener {
job.cancel()
token?.let { value ->
ProviderRequests.remove(value)
scope.launch { credentials.cancel(value) }
}
}
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
scope.launch {
try {
val structure = request.fillContexts.last().structure
val facts = autofillFacts(structure, credentials)
fun value(id: android.view.autofill.AutofillId): String {
fun find(node: AssistStructure.ViewNode): String? {
if (node.autofillId ==
id
) {
return node.autofillValue?.takeIf {
it.isText
}?.textValue?.toString()
}
for (index in 0 until node.childCount) {
find(node.getChildAt(index))?.let {
return it
}
}
return null
}
for (index in 0 until structure.windowNodeCount) {
find(
structure.getWindowNodeAt(index).rootViewNode
)?.let {
return it
}
}
error("The system did not supply the credential field.")
}
val result = withTimeout(5_000) {
credentials.begin(facts.caller).also {
credentials.stage(
it.token,
facts.caller,
value(facts.username),
value(facts.password)
)
}
}
val pending = ProviderRequests.put(
this@IronAutofillService,
ProviderContext(result.token, facts.caller, "save"),
"save"
)
if (Build.VERSION.SDK_INT >= 28) {
callback.onSuccess(pending.intentSender)
} else {
pending.send()
callback.onSuccess()
}
} catch (
_: Exception
) {
callback.onFailure(
"IronStorage could not offer this save. Open the entry editor to save manually."
)
}
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
@@ -0,0 +1,180 @@
package de.rfc1437.ironstorage.providers
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.annotation.RequiresApi
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.BeginGetPasswordOption
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.PasswordCredentialEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@RequiresApi(34)
@AndroidEntryPoint
class IronCredentialProviderService : CredentialProviderService() {
@Inject lateinit var credentials: NativeCredentialRepository
@Inject lateinit var authentication: AuthenticationRepository
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>
) {
var token: String? = null
val job = scope.launch {
try {
val caller = credentialCaller(requireNotNull(request.callingAppInfo), credentials)
val options = request.beginGetCredentialOptions
.filterIsInstance<BeginGetPasswordOption>()
require(options.isNotEmpty() && options.size <= 16)
val result = withTimeout(5_000) { credentials.begin(caller) }
token = result.token
if (cancellationSignal.isCanceled) {
credentials.cancel(result.token)
return@launch
}
val budget = (20 / options.size - 1).coerceAtLeast(0)
val entries = options.flatMapIndexed { optionIndex, option ->
val candidates = result.candidates.take(budget).mapIndexed { index, candidate ->
PasswordCredentialEntry(
this@IronCredentialProviderService,
candidate.path,
ProviderRequests.put(
this@IronCredentialProviderService,
ProviderContext(
result.token,
caller,
"credential",
candidate.path,
allowedUsernames = option.allowedUserIds.toList()
),
"$optionIndex/$index"
),
option,
displayName = candidate.path,
isAutoSelectAllowed = false
)
}
// Manual search retains the selected OS option and its account constraint.
val search = PasswordCredentialEntry(
this@IronCredentialProviderService,
"Search IronStorage",
ProviderRequests.put(
this@IronCredentialProviderService,
ProviderContext(
result.token,
caller,
"credential",
allowedUsernames = option.allowedUserIds.toList()
),
"$optionIndex/search"
),
option,
isAutoSelectAllowed = false
)
candidates + search
}
callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))
} catch (_: Exception) {
if (!cancellationSignal.isCanceled) {
callback.onError(
GetCredentialUnknownException("IronStorage could not prepare this request.")
)
}
}
}
cancellationSignal.setOnCancelListener {
job.cancel()
token?.let { value ->
ProviderRequests.remove(value)
scope.launch { credentials.cancel(value) }
}
}
}
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
) {
var token: String? = null
val job = scope.launch {
try {
require(request is BeginCreatePasswordCredentialRequest)
val caller = credentialCaller(requireNotNull(request.callingAppInfo), credentials)
val result = withTimeout(5_000) { credentials.begin(caller) }
token = result.token
if (cancellationSignal.isCanceled) {
credentials.cancel(result.token)
return@launch
}
callback.onResult(
BeginCreateCredentialResponse(
listOf(
CreateEntry(
"IronStorage",
ProviderRequests.put(
this@IronCredentialProviderService,
ProviderContext(result.token, caller, "create"),
"create"
),
description = "Choose a destination and confirm saving"
)
)
)
)
} catch (_: Exception) {
if (!cancellationSignal.isCanceled) {
callback.onError(
CreateCredentialUnknownException(
"IronStorage could not prepare this save request."
)
)
}
}
}
cancellationSignal.setOnCancelListener {
job.cancel()
token?.let { value ->
ProviderRequests.remove(value)
scope.launch { credentials.cancel(value) }
}
}
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>
) {
ProviderRequests.clear()
authentication.lock()
callback.onResult(null)
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
@@ -0,0 +1,169 @@
package de.rfc1437.ironstorage.providers
import android.app.PendingIntent
import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.view.autofill.AutofillId
import androidx.annotation.RequiresApi
import androidx.credentials.provider.CallingAppInfo
import de.rfc1437.ironstorage.MainActivity
import de.rfc1437.ironstorage.core.CredentialAttribute
import de.rfc1437.ironstorage.core.CredentialCaller
import de.rfc1437.ironstorage.core.CredentialField
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
internal data class ProviderContext(
val token: String,
val caller: CredentialCaller,
val mode: String,
val selected: String? = null,
val username: AutofillId? = null,
val password: AutofillId? = null,
val allowedUsernames: List<String> = emptyList(),
val created: Long = android.os.SystemClock.elapsedRealtime()
)
/** Contains only request metadata. Lost process state always requires a new system request. */
internal object ProviderRequests {
private val requests = ConcurrentHashMap<String, ProviderContext>()
fun attribution(context: Context): PendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java)
.setAction("de.rfc1437.ironstorage.OPEN_PROVIDER"),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
fun put(context: Context, request: ProviderContext, suffix: String): PendingIntent {
val now = android.os.SystemClock.elapsedRealtime()
requests.entries.removeIf { now - it.value.created >= 120_000 }
require(requests.size < 512)
val key = "${request.token}/$suffix"
requests[key] = request
val intent = Intent(context, CredentialSelectionActivity::class.java)
.setAction("de.rfc1437.ironstorage.SELECT_CREDENTIAL")
.setData(
Uri.Builder().scheme(
"ironstorage-provider"
).authority("request").appendEncodedPath(key).build()
)
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
fun get(intent: Intent): ProviderContext? =
intent.data?.path?.removePrefix("/")?.let(requests::get)
fun remove(token: String) {
requests.entries.removeIf { it.value.token == token }
}
fun clear() {
requests.clear()
}
}
internal fun Context.installedCaller(packageName: String): CredentialCaller {
@Suppress("DEPRECATION")
val info = packageManager.getPackageInfo(
packageName,
if (Build.VERSION.SDK_INT >=
28
) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
PackageManager.GET_SIGNATURES
}
)
@Suppress("DEPRECATION")
val signatures = if (Build.VERSION.SDK_INT >=
28
) {
info.signingInfo?.apkContentsSigners
} else {
info.signatures
}
val fingerprints = requireNotNull(signatures).map { signature ->
MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString("") {
"%02X".format(it)
}
}
return CredentialCaller(packageName, fingerprints, null)
}
@RequiresApi(34)
internal suspend fun Context.credentialCaller(
info: CallingAppInfo,
credentials: NativeCredentialRepository
): CredentialCaller {
val installed = installedCaller(info.packageName)
val supplied = info.signingInfoCompat.apkContentsSigners.map { signature ->
MessageDigest.getInstance("SHA-256").digest(signature.toByteArray()).joinToString("") {
"%02X".format(it)
}
}
require(installed.certificates.sorted() == supplied.sorted())
val origin = try {
info.getOrigin(credentials.privilegedCallers())
} catch (
_: IllegalStateException
) {
null
}
return installed.copy(origin = origin)
}
internal data class AutofillFacts(
val caller: CredentialCaller,
val username: AutofillId,
val password: AutofillId
)
internal suspend fun Context.autofillFacts(
structure: AssistStructure,
credentials: NativeCredentialRepository
): AutofillFacts {
val caller = installedCaller(structure.activityComponent.packageName)
val ids = mutableListOf<AutofillId>()
val fields = mutableListOf<CredentialField>()
var visited = 0
fun visit(node: AssistStructure.ViewNode, depth: Int) {
require(depth < 64 && ++visited <= 2048)
node.autofillId?.let { id ->
require(fields.size < 512)
val existing = ids.indexOf(id)
val index = if (existing < 0) {
ids += id
(ids.size - 1).toUInt()
} else {
existing.toUInt()
}
fields += CredentialField(
index,
node.autofillHints?.toList() ?: emptyList(),
node.htmlInfo?.attributes?.map { CredentialAttribute(it.first, it.second) }
?: emptyList(),
node.inputType.toUInt(),
node.isFocused,
node.autofillType == android.view.View.AUTOFILL_TYPE_TEXT
)
}
for (index in 0 until node.childCount) visit(node.getChildAt(index), depth + 1)
}
require(structure.windowNodeCount <= 16)
for (index in 0 until structure.windowNodeCount) {
visit(
structure.getWindowNodeAt(index).rootViewNode,
0
)
}
val form = credentials.form(fields)
return AutofillFacts(caller, ids[form.username.toInt()], ids[form.password.toInt()])
}
@@ -0,0 +1 @@
<resources><bool name="credential_provider_enabled">true</bool></resources>
@@ -0,0 +1 @@
<resources><bool name="credential_provider_enabled">false</bool></resources>
@@ -0,0 +1 @@
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android" android:settingsActivity="de.rfc1437.ironstorage.MainActivity" />
@@ -0,0 +1,3 @@
<credential-provider xmlns:android="http://schemas.android.com/apk/res/android" android:settingsActivity="de.rfc1437.ironstorage.MainActivity">
<capabilities><capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" /></capabilities>
</credential-provider>