Skip to content
Merged
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
4 changes: 3 additions & 1 deletion app/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.compose.material3.navigation.suite)
implementation(libs.androidx.compose.adaptive)
implementation(libs.androidx.compose.adaptive.layout)
implementation(libs.androidx.navigation.compose)
debugImplementation(libs.androidx.compose.ui.tooling)

Expand All @@ -105,7 +108,6 @@ dependencies {
implementation(libs.retrofit)
implementation(libs.okhttp)
implementation(libs.androidx.browser)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
Expand Down
7 changes: 6 additions & 1 deletion app/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:supportsRtl="true"
android:theme="@style/Theme.FlyFunForms">
<activity
Expand All @@ -39,6 +40,10 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="flyfunforms" android:host="auth" />
</intent-filter>

<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>

<provider
Expand Down
21 changes: 17 additions & 4 deletions app/android/app/src/main/kotlin/aero/flyfun/forms/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import aero.flyfun.forms.auth.TokenStore
import aero.flyfun.forms.data.FormFiles
import aero.flyfun.forms.net.ApiClient
import aero.flyfun.forms.net.ApiConfig
import aero.flyfun.forms.ui.AppShortcut
import aero.flyfun.forms.ui.FlyFunApp
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.activity.enableEdgeToEdge
import aero.flyfun.forms.ui.FlyFunTheme
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
Expand All @@ -21,10 +24,16 @@ class MainActivity : ComponentActivity() {
private lateinit var api: ApiClient
private lateinit var auth: AuthService

/** A launcher shortcut waiting for the UI to act on it; see res/xml/shortcuts.xml. */
private val shortcut = MutableStateFlow<AppShortcut?>(null)

/** The redirect already handled, so a recreated activity does not handle it twice. */
private var handledCallback: String? = null

override fun onCreate(savedInstanceState: Bundle?) {
// Target 35+ draws edge to edge regardless; this also makes the system
// bar icons follow the theme, light or dark.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
tokens = TokenStore(this)
api = ApiClient(tokens)
Expand All @@ -38,12 +47,15 @@ class MainActivity : ComponentActivity() {
}

setContent {
MaterialTheme {
FlyFunApp(auth = auth, tokens = tokens, api = api)
FlyFunTheme {
FlyFunApp(auth = auth, tokens = tokens, api = api, shortcut = shortcut)
}
}
handledCallback = savedInstanceState?.getString(KEY_HANDLED_CALLBACK)
handleAuthRedirect(intent)
// Only on a fresh start: a recreated activity keeps its old intent,
// and the shortcut was acted on the first time.
if (savedInstanceState == null) shortcut.value = AppShortcut.from(intent?.action)
}

override fun onSaveInstanceState(outState: Bundle) {
Expand All @@ -65,6 +77,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
handleAuthRedirect(intent)
AppShortcut.from(intent.action)?.let { shortcut.value = it }
}

private companion object {
Expand All @@ -85,7 +98,7 @@ class MainActivity : ComponentActivity() {
.onFailure {
Toast.makeText(
this@MainActivity,
it.message ?: "Sign-in failed",
it.message ?: getString(R.string.app_sign_in_failed),
Toast.LENGTH_LONG,
).show()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package aero.flyfun.forms.auth

import aero.flyfun.forms.R
import aero.flyfun.forms.net.ApiClient
import aero.flyfun.forms.net.ApiConfig
import aero.flyfun.forms.net.ExchangeRequest
Expand All @@ -12,6 +13,19 @@ import kotlinx.coroutines.flow.asStateFlow
import java.security.SecureRandom
import android.util.Base64

/** The sign-in providers the flyfun server offers, by their path segment in `/auth/login/{provider}`. */
enum class SignInProvider(val path: String) {
GOOGLE("google"),

/**
* Sign in with Apple through the same web flow as Google: Apple posts back
* to the server (`response_mode=form_post`), which then redirects to the
* app with the auth code, so nothing Apple-specific happens on Android.
* The native `POST /auth/apple/token` route needs the iOS SDK.
*/
APPLE("apple"),
}

/**
* Google / Apple sign-in through a Chrome Custom Tab.
*
Expand Down Expand Up @@ -69,12 +83,12 @@ class AuthService(
}.commit()
}

fun startSignIn(provider: String = "google") {
fun startSignIn(provider: SignInProvider) {
val state = newState().also { pendingState = it }
val url = Uri.parse(ApiConfig.BASE_URL).buildUpon()
.appendPath("auth")
.appendPath("login")
.appendPath(provider)
.appendPath(provider.path)
// The server's native branch keys off `platform=ios`. That name is
// historical - it means "native app", not the OS - and it is what
// selects the custom-scheme redirect instead of a web session
Expand Down Expand Up @@ -106,11 +120,11 @@ class AuthService(
// no `state`. We always send one, so seeing this means something
// else produced the redirect - refuse it rather than trusting a
// token that arrived over a scheme any app can claim.
return Result.failure(IllegalStateException("Sign-in did not return an auth code"))
return Result.failure(IllegalStateException(context.getString(R.string.app_sign_in_no_code)))
}
val expected = pendingState
if (expected == null || state != expected) {
return Result.failure(IllegalStateException("Sign-in state did not match"))
return Result.failure(IllegalStateException(context.getString(R.string.app_sign_in_state_mismatch)))
}
pendingState = null

Expand Down Expand Up @@ -138,9 +152,9 @@ class AuthService(
if (response.code() == 401) {
// ApiClient has already dropped the token, so the sign-in screen
// replaces Settings before its error could show. Say it there.
_signInNotice.value = "Your session had expired, so your account was not deleted. Sign in again to delete it."
_signInNotice.value = context.getString(R.string.app_delete_account_expired)
}
if (!response.isSuccessful) error("The server returned ${response.code()}. Your account was not deleted.")
if (!response.isSuccessful) error(context.getString(R.string.app_delete_account_server_error, response.code()))
tokens.clear()
}

Expand Down
102 changes: 84 additions & 18 deletions app/android/app/src/main/kotlin/aero/flyfun/forms/auth/TokenStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,57 @@ package aero.flyfun.forms.auth

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

/**
* The session JWT, in EncryptedSharedPreferences backed by a Keystore master
* key - the nearest equivalent to the iOS app's Keychain storage.
* The session JWT, encrypted with an AES-GCM key that never leaves the Android
* Keystore - the nearest equivalent to the iOS app's Keychain storage.
*
* App-private storage is already encrypted at rest on modern Android, but only
* up to first unlock after boot. A bearer token for an account holding passport
* data is worth the extra key.
*
* Replaces `EncryptedSharedPreferences`, which androidx.security deprecated:
* one value does not need an encrypted key-value store, only its own key. The
* ciphertext (IV first) sits in plain app-private preferences.
*/
class TokenStore(context: Context) {

private val prefs: SharedPreferences = run {
val key = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"flyfun-auth",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)

init {
// The EncryptedSharedPreferences file an earlier build kept the token
// in. Not carried over: the app was never released, and signing in
// once more is the whole cost.
context.deleteSharedPreferences(LEGACY_PREFS)
}

private val _signedIn = MutableStateFlow(prefs.getString(KEY_TOKEN, null) != null)
@Volatile
private var cached: String? = read()

private val _signedIn = MutableStateFlow(cached != null)

/** Observed by the UI, so a sign-out or an expired token shows the sign-in screen. */
val signedIn: StateFlow<Boolean> = _signedIn.asStateFlow()

var token: String?
get() = prefs.getString(KEY_TOKEN, null)
get() = cached
@Synchronized
set(value) {
cached = value
val stored = value?.let { runCatching { encrypt(it) }.getOrNull() }
prefs.edit().apply {
if (value == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, value)
if (stored == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, stored)
}.apply()
_signedIn.value = value != null
}
Expand All @@ -60,7 +72,61 @@ class TokenStore(context: Context) {
if (token == rejected) clear()
}

/**
* Take the successor the server minted for [sent] as it neared expiry
* (flyfun-common's rolling sessions, `X-Renewed-Token`). Only while [sent]
* is still the one held: a sign-out or a newer renewal meanwhile wins.
*/
@Synchronized
fun replaceIfCurrent(sent: String, renewed: String) {
if (token == sent && renewed.isNotBlank()) token = renewed
}

/** The stored token, or null when there is none or it no longer decrypts (key lost with a reset lock screen, say). */
private fun read(): String? {
val stored = prefs.getString(KEY_TOKEN, null) ?: return null
return runCatching { decrypt(stored) }.getOrElse {
prefs.edit().remove(KEY_TOKEN).apply()
null
}
}

private fun key(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return generator.generateKey()
}

private fun encrypt(plain: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key())
val sealed = cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(sealed, Base64.NO_WRAP)
}

private fun decrypt(stored: String): String {
val sealed = Base64.decode(stored, Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, sealed, 0, IV_BYTES))
return String(cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES), Charsets.UTF_8)
}

private companion object {
const val PREFS = "flyfun-session"
const val LEGACY_PREFS = "flyfun-auth"
const val KEY_TOKEN = "session_jwt"
const val KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "flyfun-session-token"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_BYTES = 12
const val TAG_BITS = 128
}
}
43 changes: 41 additions & 2 deletions app/android/app/src/main/kotlin/aero/flyfun/forms/net/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ object ApiConfig {
/** The public privacy notice, served by the same backend. Linked from Settings. */
const val PRIVACY_URL = "${BASE_URL}privacy"

/** Flights planned in FlyFun Weather, for import; see [WeatherApi]. */
const val WEATHER_URL = "https://weather.flyfun.aero/"

/** Reused from iOS: the allowlist already contains it, and the two platforms cannot collide on one device. */
const val CALLBACK_SCHEME = "flyfunforms"
const val CALLBACK_URL = "$CALLBACK_SCHEME://auth/callback"
}

class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE_URL) {

private companion object {
/** flyfun-common `SlidingSessionMiddleware`: the renewed JWT for a Bearer request. */
const val RENEWED_TOKEN_HEADER = "X-Renewed-Token"
}

private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
Expand All @@ -45,15 +53,30 @@ class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE
* the UI, which observes [TokenStore.signedIn], goes back to sign-in -
* rather than every later request failing with the same 401.
*/
private val authInterceptor = Interceptor { chain ->
private val authInterceptor = bearer(signOutOn401 = true)

/**
* The same account's token for another flyfun service. A 401 there says
* that service would not take it, not that the forms session is over, so
* it signs nothing out; renewals are still kept.
*/
private fun bearer(signOutOn401: Boolean) = Interceptor { chain ->
val token = tokens.token
val request = if (token != null) {
chain.request().newBuilder().addHeader("Authorization", "Bearer $token").build()
} else {
chain.request()
}
val response = chain.proceed(request)
if (response.code == 401 && token != null) tokens.clearIfCurrent(token)
if (token != null) {
if (response.code == 401) {
if (signOutOn401) tokens.clearIfCurrent(token)
} else {
// Rolling sessions: a token near expiry comes back with its
// successor, so a pilot who keeps using the app stays signed in.
response.header(RENEWED_TOKEN_HEADER)?.let { tokens.replaceIfCurrent(token, it) }
}
}
response
}

Expand Down Expand Up @@ -82,6 +105,22 @@ class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE
.build()
.create(NotificationsApi::class.java)

/**
* FlyFun Weather, signed in as the same account: the flyfun services
* share the account and its tokens, as on iOS (`RollingBearerSession`).
*/
val weather: WeatherApi = Retrofit.Builder()
.baseUrl(ApiConfig.WEATHER_URL)
.client(
OkHttpClient.Builder()
.addInterceptor(bearer(signOutOn401 = false))
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build(),
)
.build()
.create(WeatherApi::class.java)

/** Parses a 422 body into the structured errors the UI shows. */
fun parseValidationErrors(body: String): List<ServerValidationError> = runCatching {
json.decodeFromString(ValidationErrorEnvelope.serializer(), body).detail
Expand Down
Loading
Loading