Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ android {
buildFeatures {
buildConfig = true
}

testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}

dependencies {
Expand Down Expand Up @@ -42,6 +48,8 @@ dependencies {
implementation(projects.feature.account.avatar.impl)
implementation(projects.feature.account.settings.api)
implementation(projects.feature.account.setup)
implementation(projects.feature.applock.api)
implementation(projects.feature.applock.impl)
implementation(projects.feature.mail.account.api)
implementation(projects.feature.mail.message.composer)
implementation(projects.feature.migration.provider)
Expand All @@ -64,6 +72,7 @@ dependencies {

testImplementation(projects.feature.account.fake)
testImplementation(projects.core.testing)
testImplementation(projects.core.android.testing)
}

codeCoverage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import net.thunderbird.app.common.feature.LoggerLifecycleObserver
import net.thunderbird.app.common.feature.applock.AppLockActivityLifecycleCallbacks
import net.thunderbird.core.common.exception.ExceptionHandler
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.logging.file.FileLogSink
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.ui.theme.manager.ThemeManager
import net.thunderbird.feature.applock.api.AppLockGate
import org.koin.android.ext.android.getKoin
import org.koin.android.ext.android.inject
import org.koin.core.module.Module
import org.koin.core.qualifier.named
Expand Down Expand Up @@ -75,6 +78,12 @@ abstract class BaseApplication : Application(), WorkManagerConfiguration.Provide
Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(originalHandler))

ProcessLifecycleOwner.get().lifecycle.addObserver(LoggerLifecycleObserver(syncDebugFileLogSink))

registerActivityLifecycleCallbacks(
AppLockActivityLifecycleCallbacks(
gateFactory = getKoin().getOrNull(AppLockGate.Factory::class),
),
)
}

abstract fun provideAppModule(): Module
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import app.k9mail.feature.launcher.FeatureLauncherExternalContract
import app.k9mail.feature.launcher.di.featureLauncherModule
import net.thunderbird.app.common.feature.mail.appCommonFeatureMailModule
import net.thunderbird.feature.account.avatar.di.featureAccountAvatarModule
import net.thunderbird.feature.applock.impl.featureAppLockModule
import net.thunderbird.feature.mail.message.composer.inject.featureMessageComposerModule
import net.thunderbird.feature.mail.message.reader.impl.inject.featureMessageReaderModule
import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract
Expand All @@ -14,6 +15,7 @@ import org.koin.dsl.module
internal val appCommonFeatureModule = module {
includes(appCommonFeatureMailModule)
includes(featureAccountAvatarModule)
includes(featureAppLockModule)
includes(featureLauncherModule)
includes(featureNotificationModule)
includes(featureMessageComposerModule)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package net.thunderbird.app.common.feature.applock

import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import net.thunderbird.feature.applock.api.AppLockGate

internal class AppLockActivityLifecycleCallbacks(
private val gateFactory: AppLockGate.Factory?,
) : ActivityLifecycleCallbacks {

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (gateFactory == null) return
if (activity !is FragmentActivity) return

val gate = gateFactory.create(activity)
activity.lifecycle.addObserver(gate)
}

override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package net.thunderbird.app.common.feature.applock

import android.app.Activity
import android.os.Build
import androidx.fragment.app.FragmentActivity
import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.single
import net.thunderbird.core.android.testing.RobolectricTest
import net.thunderbird.feature.applock.api.AppLockGate
import org.junit.Test
import org.robolectric.Robolectric
import org.robolectric.annotation.Config

@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class AppLockActivityLifecycleCallbacksTest : RobolectricTest() {

@Test
fun `should not crash when factory is null`() {
val testSubject = AppLockActivityLifecycleCallbacks(gateFactory = null)
val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()

testSubject.onActivityCreated(activity, null)
}

@Test
fun `should create gate for FragmentActivity`() {
val fakeFactory = FakeAppLockGateFactory()
val testSubject = AppLockActivityLifecycleCallbacks(gateFactory = fakeFactory)
val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get()

testSubject.onActivityCreated(activity, null)

assertThat(fakeFactory.createdActivities).single().isEqualTo(activity)
}

@Test
fun `should not create gate for plain Activity`() {
val fakeFactory = FakeAppLockGateFactory()
val testSubject = AppLockActivityLifecycleCallbacks(gateFactory = fakeFactory)
val activity = Robolectric.buildActivity(Activity::class.java).create().get()

testSubject.onActivityCreated(activity, null)

assertThat(fakeFactory.createdActivities).isEmpty()
}

private class FakeAppLockGateFactory : AppLockGate.Factory {
val createdActivities = mutableListOf<FragmentActivity>()

override fun create(activity: FragmentActivity): AppLockGate {
createdActivities.add(activity)
return FakeAppLockGate()
}
}

private class FakeAppLockGate : AppLockGate
}
16 changes: 16 additions & 0 deletions feature/applock/api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}

kotlin {
androidLibrary {
namespace = "net.thunderbird.feature.applock.api"
withHostTest {}
}
sourceSets {
commonMain.dependencies {
api(projects.core.outcome)
api(libs.kotlinx.coroutines.core)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.thunderbird.feature.applock.api

import androidx.fragment.app.FragmentActivity

/**
* Factory for creating [AppLockAuthenticator] instances bound to a specific activity.
*
* This allows modules that depend on the API to create authenticators without
* depending on the concrete implementation (e.g., BiometricAuthenticator).
*/
fun interface AppLockAuthenticatorFactory {
fun create(activity: FragmentActivity): AppLockAuthenticator
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package net.thunderbird.feature.applock.api

import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.DefaultLifecycleObserver

/**
* Lifecycle-aware component that handles app lock UI and authentication.
*
* Add this observer to an Activity's lifecycle to automatically:
* - Show/hide a lock overlay when the app is locked
* - Trigger biometric authentication when needed
* - Handle authentication results
*
* Usage:
* ```
* class MyActivity : AppCompatActivity() {
* private val appLockGate: AppLockGate by inject { parametersOf(this) }
*
* override fun onCreate(savedInstanceState: Bundle?) {
* super.onCreate(savedInstanceState)
* lifecycle.addObserver(appLockGate)
* }
* }
* ```
*/
interface AppLockGate : DefaultLifecycleObserver {
/**
* Factory for creating [AppLockGate] instances bound to a specific activity.
*/
interface Factory {
/**
* Create an [AppLockGate] for the given activity.
*
* @param activity The FragmentActivity to bind to (needed for BiometricPrompt)
*/
fun create(activity: FragmentActivity): AppLockGate
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.thunderbird.feature.applock.api

import android.content.Context
import android.content.Intent

fun interface AppLockSettingsNavigation {
fun createIntent(context: Context): Intent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.thunderbird.feature.applock.api

/**
* Functional interface for authenticating a user.
*
* This abstraction allows for different authentication implementations
* (biometric, device credential, etc.) and testing with fakes.
*/
fun interface AppLockAuthenticator {
/**
* Authenticate the user.
*
* @return An [AppLockResult] representing the outcome.
*/
suspend fun authenticate(): AppLockResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package net.thunderbird.feature.applock.api

/**
* Configuration settings for app lock.
*
* @property isEnabled Whether biometric/device authentication is enabled.
* @property timeoutMillis Timeout in milliseconds after which re-authentication is required
* when the app returns from background. Use 0 for immediate re-authentication.
*/
data class AppLockConfig(
val isEnabled: Boolean = DEFAULT_ENABLED,
val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS,
) {
companion object {
/**
* Default: App lock is disabled.
*/
const val DEFAULT_ENABLED = false

/**
* Default timeout: 0 (immediate re-authentication required when returning from background).
*/
const val DEFAULT_TIMEOUT_MILLIS = 0L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package net.thunderbird.feature.applock.api

import kotlinx.coroutines.flow.StateFlow

/**
* Coordinates app lock flow and orchestration.
*
* This is the main public API for the app lock feature. Other modules should
* only interact with app lock through this interface.
*
* Uses a pull model: Activities observe [state] and call [ensureUnlocked] when
* they need to ensure the app is unlocked. No effect bus is used for prompting.
*
* **Threading contract:** All methods must be called on the main thread.
* State mutations are not thread-safe. Callers must not invoke methods from
* background threads or coroutine dispatchers other than [Dispatchers.Main].
*/
interface AppLockCoordinator {
/**
* Observable app lock state for UI rendering.
*/
val state: StateFlow<AppLockState>

/**
* Current app lock configuration.
*/
val config: AppLockConfig

/**
* Whether app lock is currently enabled in settings.
*/
val isEnabled: Boolean
get() = config.isEnabled

/**
* Whether authentication (biometric or device credential) is available on this device.
*/
val isAuthenticationAvailable: Boolean

/**
* Notify that the app came to foreground.
*/
fun onAppForegrounded()

/**
* Notify that the app went to background.
*/
fun onAppBackgrounded()

/**
* Notify that the screen turned off. Immediately locks the app if enabled.
*/
fun onScreenOff()

/**
* Lock the app immediately.
*/
fun lockNow()

/**
* Request unlock.
*
* Call this from Activity.onResume() when state is not Unlocked/Disabled.
* Transitions Locked/Failed → Unlocking if not already unlocking.
*
* @return true if unlock was initiated or already unlocked/disabled,
* false if already unlocking (caller should wait, not show duplicate prompt)
*/
fun ensureUnlocked(): Boolean

/**
* Update app lock configuration.
*/
fun onSettingsChanged(config: AppLockConfig)

/**
* Authenticate using the provided authenticator.
* Call this when state is [AppLockState.Unlocking].
*/
suspend fun authenticate(authenticator: AppLockAuthenticator): AppLockResult

/**
* Authenticate and enable app lock in a single operation.
*
* Unlike [onSettingsChanged], this authenticates *before* persisting the config change.
* On success, config is persisted with `isEnabled = true` and state transitions to Unlocked.
* On failure, no config or state change occurs.
*
* @return [AppLockResult] indicating success or the authentication error.
*/
suspend fun requestEnable(authenticator: AppLockAuthenticator): AppLockResult

/**
* Re-check authentication availability after returning from device settings.
* Transitions Unavailable -> Locked if auth is now available.
*/
fun refreshAvailability()
}
Loading