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
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,26 @@ class DataTransferTest {
assertEquals(listOf("Eriksson"), target.flightDao().crewOn(flightId).map { it.lastName })
}

@Test
fun trip_extra_fields_survive_the_round_trip() = runTest {
val trip = TripEntity(name = "Alps", extraFieldsJson = """{"reason_for_visit":"Maintenance"}""")
source.tripDao().upsert(trip)
// Generated, as the real export does, rather than a literal a secret
// scanner reads as a hardcoded password.
val password = aero.flyfun.forms.logic.DataFileCrypto.generatePassphrase().toCharArray()

val bytes = DataTransfer(source).exportEncrypted("test", password)
val incoming = DataTransfer(target)
incoming.preview(bytes, password.copyOf()).let { incoming.apply(it.second) }

val stored = target.tripDao().byId(trip.id)
assertNotNull(stored)
assertEquals(
mapOf("reason_for_visit" to "Maintenance"),
aero.flyfun.forms.logic.TripExtras.decode(stored!!.extraFieldsJson),
)
}

@Test
fun re_importing_the_same_file_changes_nothing() = runTest {
populate(source)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,29 @@ class FlyFunDatabaseTest {
assertEquals(Instant.parse("2026-09-19T12:00:00Z"), row!!.person.deletedAt)
}

@Test
fun undo_restores_a_tombstoned_person_flight_and_aircraft() = runTest {
val p = person("Gita", "Rao")
val ac = AircraftEntity(registration = "G-ABCD", type = "SR22").also { aircraft.upsert(it) }
val f = FlightEntity(departureInstant = dep, arrivalInstant = arr, aircraftId = ac.id)
.also { flights.upsert(it) }
val deleted = Instant.parse("2026-09-19T12:00:00Z")
val undone = Instant.parse("2026-09-19T12:00:05Z")

people.softDelete(p.id, deleted)
aircraft.softDelete(ac.id, deleted)
flights.softDelete(f.id, deleted)
people.restore(p.id, undone)
aircraft.restore(ac.id, undone)
flights.restore(f.id, undone)

assertEquals(listOf(p.id), people.observeAll().first().map { it.person.id })
assertEquals(listOf(ac.id), aircraft.all().map { it.id })
assertEquals(listOf(f.id), flights.observeAll().first().map { it.id })
// Newer than the tombstone, so an import carrying the deletion loses.
assertEquals(undone, people.byId(p.id)!!.person.updatedAt)
}

@Test
fun documents_come_back_with_their_person_and_calendar_dates_survive() = runTest {
val p = person("Hugo", "Silva")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package aero.flyfun.forms

import aero.flyfun.forms.auth.AuthService
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.FlyFunApp
Expand All @@ -20,20 +21,42 @@ class MainActivity : ComponentActivity() {
private lateinit var api: ApiClient
private lateinit var auth: AuthService

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

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
tokens = TokenStore(this)
api = ApiClient(tokens)
auth = AuthService(this, api, tokens)

// Forms left from an earlier run carry passport data; nothing can
// still be reading them now. See FormFiles.
if (!purgedThisProcess) {
purgedThisProcess = true
FormFiles.purge(cacheDir)
}

setContent {
MaterialTheme {
FlyFunApp(auth = auth, tokens = tokens, api = api)
}
}
handledCallback = savedInstanceState?.getString(KEY_HANDLED_CALLBACK)
handleAuthRedirect(intent)
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(KEY_HANDLED_CALLBACK, handledCallback)
}

override fun onResume() {
super.onResume()
// Back from a share: whatever took the file has had time to read it.
FormFiles.purge(cacheDir, olderThan = FormFiles.SHARE_GRACE)
}

/**
* The activity is singleTask, so the OAuth redirect arrives here rather
* than starting a second copy on top of the running task.
Expand All @@ -44,12 +67,21 @@ class MainActivity : ComponentActivity() {
handleAuthRedirect(intent)
}

private companion object {
var purgedThisProcess = false
const val KEY_HANDLED_CALLBACK = "handled_auth_callback"
}

private fun handleAuthRedirect(intent: Intent?) {
val uri = intent?.data ?: return
if (uri.scheme != ApiConfig.CALLBACK_SCHEME) return
// Not again on rotation: the nonce is spent, and a second pass would
// report a sign-in that just worked as failed.
if (uri.toString() == handledCallback) return
handledCallback = uri.toString()
lifecycleScope.launch {
// Success needs nothing here: the UI observes TokenStore.signedIn.
auth.handleCallback(uri)
.onSuccess { recreate() }
.onFailure {
Toast.makeText(
this@MainActivity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,34 @@ class AuthService(
private val tokens: TokenStore,
) {

/** Held between launching the tab and handling the redirect. */
private var pendingState: String? = null
/**
* The `state` nonce, held between launching the tab and handling the
* redirect.
*
* On disk rather than in a field: while the Custom Tab is in front, this
* process is in the background and may be killed, and the redirect then
* starts a fresh one that would refuse the callback. It is a one-use
* anti-forgery nonce, not a credential, so plain app-private preferences
* are enough; it expires after [PENDING_TTL_MILLIS] so an abandoned
* sign-in cannot be completed much later.
*/
private val pending = context.getSharedPreferences("flyfun-auth-pending", Context.MODE_PRIVATE)

private var pendingState: String?
get() {
val state = pending.getString(KEY_STATE, null) ?: return null
val age = System.currentTimeMillis() - pending.getLong(KEY_STARTED, 0)
return state.takeIf { age in 0..PENDING_TTL_MILLIS }
}
set(value) {
pending.edit().apply {
if (value == null) {
remove(KEY_STATE); remove(KEY_STARTED)
} else {
putString(KEY_STATE, value); putLong(KEY_STARTED, System.currentTimeMillis())
}
}.commit()
}

fun startSignIn(provider: String = "google") {
val state = newState().also { pendingState = it }
Expand Down Expand Up @@ -89,8 +115,26 @@ class AuthService(
tokens.clear()
}

/**
* Delete the account on the server, then sign out here.
*
* People, aircraft and flights stay on the device: they were never on the
* server, and the pilot may want to keep using the app offline.
*/
suspend fun deleteAccount(): Result<Unit> = runCatching {
val response = api.auth.deleteAccount()
if (!response.isSuccessful) error("The server returned ${response.code()}. Your account was not deleted.")
tokens.clear()
}

val isSignedIn: Boolean get() = tokens.isSignedIn

private companion object {
const val KEY_STATE = "state"
const val KEY_STARTED = "started_at"
const val PENDING_TTL_MILLIS = 10 * 60 * 1000L
}

private fun newState(): String {
val bytes = ByteArray(24)
SecureRandom().nextBytes(bytes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

/**
* The session JWT, in EncryptedSharedPreferences backed by a Keystore master
Expand All @@ -28,18 +31,35 @@ class TokenStore(context: Context) {
)
}

private val _signedIn = MutableStateFlow(prefs.getString(KEY_TOKEN, null) != 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)
set(value) = prefs.edit().apply {
if (value == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, value)
}.apply()
set(value) {
prefs.edit().apply {
if (value == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, value)
}.apply()
_signedIn.value = value != null
}

val isSignedIn: Boolean get() = token != null

fun clear() {
token = null
}

/**
* Drop [rejected] after the server answered 401 to it - unless a newer
* token has replaced it meanwhile, which a late 401 must not throw away.
*/
@Synchronized
fun clearIfCurrent(rejected: String) {
if (token == rejected) clear()
}

private companion object {
const val KEY_TOKEN = "session_jwt"
}
Expand Down
10 changes: 10 additions & 0 deletions app/android/app/src/main/kotlin/aero/flyfun/forms/data/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ interface PersonDao {
@Query("UPDATE person SET deletedAt = :at, updatedAt = :at WHERE id = :id")
suspend fun softDelete(id: String, at: Instant = Instant.now())

/** Undo a [softDelete]. The newer `updatedAt` makes the restore win a later merge. */
@Query("UPDATE person SET deletedAt = NULL, updatedAt = :at WHERE id = :id")
suspend fun restore(id: String, at: Instant = Instant.now())

@Delete
suspend fun hardDelete(person: PersonEntity)
}
Expand Down Expand Up @@ -100,6 +104,9 @@ interface AircraftDao {
@Query("UPDATE aircraft SET deletedAt = :at, updatedAt = :at WHERE id = :id")
suspend fun softDelete(id: String, at: Instant = Instant.now())

@Query("UPDATE aircraft SET deletedAt = NULL, updatedAt = :at WHERE id = :id")
suspend fun restore(id: String, at: Instant = Instant.now())

@Query("SELECT * FROM aircraft")
suspend fun allIncludingDeleted(): List<AircraftEntity>
}
Expand Down Expand Up @@ -171,6 +178,9 @@ interface FlightDao {
@Query("UPDATE flight SET deletedAt = :at, updatedAt = :at WHERE id = :id")
suspend fun softDelete(id: String, at: Instant = Instant.now())

@Query("UPDATE flight SET deletedAt = NULL, updatedAt = :at WHERE id = :id")
suspend fun restore(id: String, at: Instant = Instant.now())

@Query("SELECT * FROM flight")
suspend fun allIncludingDeleted(): List<FlightEntity>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import aero.flyfun.forms.logic.InterchangeMerge
import aero.flyfun.forms.logic.MergeSummary
import aero.flyfun.forms.logic.PersonRecord
import aero.flyfun.forms.logic.TravelDocumentRecord
import aero.flyfun.forms.logic.TripExtras
import aero.flyfun.forms.logic.TripRecord
import androidx.room.withTransaction
import java.time.Instant
Expand Down Expand Up @@ -159,12 +160,13 @@ private fun AircraftRecord.toEntity() = AircraftEntity(

private fun TripEntity.toRecord() = TripRecord(
id = id, name = name, createdAt = createdAt.toString(),
extraFields = emptyMap(),
extraFields = TripExtras.decode(extraFieldsJson),
updatedAt = updatedAt.toString(), deletedAt = deletedAt?.toString(),
)

private fun TripRecord.toEntity() = TripEntity(
id = id, name = name, createdAt = Instant.parse(createdAt),
extraFieldsJson = TripExtras.encode(extraFields),
updatedAt = Instant.parse(updatedAt), deletedAt = deletedAt?.let(Instant::parse),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface FlightRepository {
suspend fun saveAircraft(aircraft: AircraftEntity)
suspend fun deleteFlight(id: String)
suspend fun deleteAircraft(id: String)
suspend fun restoreFlight(id: String)
suspend fun restoreAircraft(id: String)

suspend fun crew(flightId: String): List<PersonEntity>
suspend fun passengers(flightId: String): List<PersonEntity>
Expand Down Expand Up @@ -50,6 +52,9 @@ class RoomFlightRepository(
override suspend fun deleteFlight(id: String) = flights.softDelete(id, Instant.now())
override suspend fun deleteAircraft(id: String) = aircraftDao.softDelete(id, Instant.now())

override suspend fun restoreFlight(id: String) = flights.restore(id, Instant.now())
override suspend fun restoreAircraft(id: String) = aircraftDao.restore(id, Instant.now())

override suspend fun crew(flightId: String) = flights.crewOn(flightId)
override suspend fun passengers(flightId: String) = flights.passengersOn(flightId)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package aero.flyfun.forms.data

import java.io.File
import java.time.Duration
import java.time.Instant

/**
* Where generated forms and data exports are written before they are shared,
* and how they are cleared.
*
* These files carry passport numbers, so they must not sit in the cache until
* the system gets round to evicting it. A file cannot be deleted the moment it
* is handed to another app - the mail app reads it after our share intent has
* returned, sometimes much later if the pilot leaves the draft open - so they
* go on the next start, and on returning to the app once a share has had
* [SHARE_GRACE] to be read.
*/
object FormFiles {

val SHARE_GRACE: Duration = Duration.ofMinutes(15)

fun dir(cacheDir: File): File = File(cacheDir, "forms").apply { mkdirs() }

/** Delete every file last written more than [olderThan] ago; [Duration.ZERO] clears them all. */
fun purge(cacheDir: File, olderThan: Duration = Duration.ZERO, now: Instant = Instant.now()) {
val cutoff = now.minus(olderThan).toEpochMilli()
File(cacheDir, "forms").listFiles()
?.filter { it.isFile && it.lastModified() <= cutoff }
?.forEach { it.delete() }
}
}
Loading
Loading