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
8,474 changes: 8,474 additions & 0 deletions app/android/app/src/main/assets/airport_timezones.csv

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package aero.flyfun.forms.contacts

import aero.flyfun.forms.logic.ContactImport
import aero.flyfun.forms.logic.ImportedContact
import android.content.Context
import android.net.Uri
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Email
import android.provider.ContactsContract.CommonDataKinds.Event
import android.provider.ContactsContract.CommonDataKinds.Phone
import android.provider.ContactsContract.CommonDataKinds.StructuredName
import android.provider.ContactsContract.CommonDataKinds.StructuredPostal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* Reads the one contact the system picker handed back.
*
* No READ_CONTACTS: `ActivityResultContracts.PickContact` grants read access
* to the picked contact only, and its entity rows (name parts, phones,
* e-mails, addresses, birthday) are read through that contact's own URI.
* Anything the provider will not give is left empty rather than failing: the
* display name alone still makes a person.
*/
object ContactReader {

suspend fun read(context: Context, contactUri: Uri): ImportedContact? = withContext(Dispatchers.IO) {
val resolver = context.contentResolver
val displayName = runCatching {
resolver.query(contactUri, arrayOf(ContactsContract.Contacts.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
}
}.getOrNull()

var first: String? = null
var last: String? = null
val phones = mutableListOf<String>()
val emails = mutableListOf<String>()
val addresses = mutableListOf<String>()
var birthday: String? = null

runCatching {
val entities = Uri.withAppendedPath(contactUri, ContactsContract.Contacts.Entity.CONTENT_DIRECTORY)
val columns = arrayOf(
ContactsContract.Contacts.Entity.MIMETYPE,
ContactsContract.Contacts.Entity.DATA1,
ContactsContract.Contacts.Entity.DATA2,
ContactsContract.Contacts.Entity.DATA3,
ContactsContract.Contacts.Entity.DATA4,
ContactsContract.Contacts.Entity.DATA7,
ContactsContract.Contacts.Entity.DATA9,
ContactsContract.Contacts.Entity.DATA10,
)
resolver.query(entities, columns, null, null, null)?.use { c ->
while (c.moveToNext()) {
fun text(i: Int) = c.getString(i)?.trim()?.takeIf { it.isNotEmpty() }
when (c.getString(0)) {
// DATA2 given name, DATA3 family name.
StructuredName.CONTENT_ITEM_TYPE -> {
first = first ?: text(2)
last = last ?: text(3)
}
Phone.CONTENT_ITEM_TYPE -> text(1)?.let { if (it !in phones) phones += it }
Email.CONTENT_ITEM_TYPE -> text(1)?.let { if (it !in emails) emails += it }
// DATA4 street, DATA7 city, DATA9 postcode, DATA10 country.
StructuredPostal.CONTENT_ITEM_TYPE ->
ContactImport.addressLine(text(4), text(5), text(6), text(7))?.let { addresses += it }
// DATA1 the date, DATA2 its type.
Event.CONTENT_ITEM_TYPE ->
if (c.getInt(2) == Event.TYPE_BIRTHDAY) birthday = birthday ?: text(1)
}
}
}
}

if (first == null && last == null) {
// Only the display name came through: "Jane van Dijk" is Jane + van Dijk.
val words = displayName?.trim()?.split(Regex("\\s+")).orEmpty().filter { it.isNotEmpty() }
if (words.isEmpty()) return@withContext null
first = if (words.size > 1) words.first() else ""
last = if (words.size > 1) words.drop(1).joinToString(" ") else words.first()
}
ImportedContact(
firstName = first.orEmpty(),
lastName = last.orEmpty(),
phones = phones,
emails = emails,
addresses = addresses,
dateOfBirth = ContactImport.parseBirthday(birthday),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package aero.flyfun.forms.data

import aero.flyfun.forms.logic.AirportSearch
import aero.flyfun.forms.logic.AirportSummary
import aero.flyfun.forms.logic.AirportTimezones
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File

/** The airport database, as the screens use it: an interface so a ViewModel can do without one. */
interface Airports {
suspend fun search(needle: String): List<AirportSummary>
suspend fun airport(icao: String): AirportSummary?
suspend fun timeZone(icao: String): String?
}

/**
* The bundled airport database (`airports.db`, copied into assets at build
* time) and the airport time zone table beside it. Port of iOS
* `AirportDatabase` + `AirportTimezoneCache`, minus the geocoding: the zones
* come from `airport_timezones.csv`, generated by `scripts/airport_timezones.py`.
*
* SQLite cannot open a file inside the APK, so the database is copied to
* no-backup storage once per build of the app. Read-only; nothing here is
* the pilot's data.
*/
class AirportDatabase private constructor(private val context: Context) : Airports {

private val mutex = Mutex()
private var db: SQLiteDatabase? = null
private var zones: Map<String, String>? = null

/** Airports matching [needle] by code, name or city, best first. Empty until there is something to search. */
override suspend fun search(needle: String): List<AirportSummary> = search(needle, 20)

suspend fun search(needle: String, limit: Int): List<AirportSummary> {
val n = AirportSearch.normalise(needle)
if (n.isEmpty()) return emptyList()
val database = open() ?: return emptyList()
return withContext(Dispatchers.IO) {
val like = "%${n.replace("%", "").replace("_", "")}%"
val candidates = database.rawQuery(
"""
SELECT $COLUMNS FROM airports
WHERE icao_code LIKE ? OR iata_code = ? OR name LIKE ? OR municipality LIKE ?
LIMIT 400
""".trimIndent(),
arrayOf("${n.uppercase()}%", n.uppercase(), like, like),
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.toSummary()) } }
AirportSearch.rank(n, candidates, limit)
}
}

/** One airport by its ICAO code, or null when it is not in the database. */
override suspend fun airport(icao: String): AirportSummary? {
if (icao.isBlank()) return null
val database = open() ?: return null
return withContext(Dispatchers.IO) {
database.rawQuery("SELECT $COLUMNS FROM airports WHERE icao_code = ?", arrayOf(icao.uppercase()))
.use { if (it.moveToFirst()) it.toSummary() else null }
}
}

/** The airport's IANA zone, or null for one the table does not know. */
override suspend fun timeZone(icao: String): String? {
if (icao.isBlank()) return null
val table = zones ?: withContext(Dispatchers.IO) {
runCatching {
context.assets.open(ZONES_ASSET).bufferedReader().use { AirportTimezones.parse(it.readText()) }
}.getOrDefault(emptyMap())
}.also { zones = it }
return table[icao.uppercase()]
}

private suspend fun open(): SQLiteDatabase? = mutex.withLock {
db ?: withContext(Dispatchers.IO) {
runCatching {
val file = copied()
SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
}.getOrNull()
}.also { db = it }
}

/**
* The asset as a file, copied again when the app is updated: the file is
* named after the app's install time, so a new build's database replaces
* the last one's.
*/
private fun copied(): File {
val stamp = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
}.getOrDefault(0L)
val dir = File(context.noBackupFilesDir, "airports").apply { mkdirs() }
val file = File(dir, "airports-$stamp.db")
if (!file.exists()) {
dir.listFiles()?.forEach { it.delete() }
val partial = File(dir, "${file.name}.part")
context.assets.open(DB_ASSET).use { input -> partial.outputStream().use { input.copyTo(it) } }
partial.renameTo(file)
}
return file
}

private fun android.database.Cursor.toSummary() = AirportSummary(
icao = getString(0).orEmpty(),
name = getString(1).orEmpty(),
city = getString(2).orEmpty(),
country = getString(3).orEmpty(),
type = getString(4).orEmpty(),
iata = getString(5)?.takeIf { it.isNotBlank() },
)

companion object {
private const val DB_ASSET = "airports.db"
private const val ZONES_ASSET = "airport_timezones.csv"
private const val COLUMNS = "icao_code, name, municipality, iso_country, type, iata_code"

@Volatile
private var instance: AirportDatabase? = null

fun get(context: Context): AirportDatabase =
instance ?: synchronized(this) {
instance ?: AirportDatabase(context.applicationContext).also { instance = it }
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package aero.flyfun.forms.data

import androidx.room.TypeConverter
import kotlinx.serialization.json.Json
import java.time.Instant
import java.time.LocalDate

Expand All @@ -27,4 +28,11 @@ class Converters {

@TypeConverter
fun instantToEpochMillis(value: Instant?): Long? = value?.toEpochMilli()

/** A JSON array: document numbers are free text, so no separator is safe. */
@TypeConverter
fun stringListFromJson(value: String?): List<String>? = value?.let { Json.decodeFromString<List<String>>(it) }

@TypeConverter
fun stringListToJson(value: List<String>?): String? = value?.let { Json.encodeToString(it) }
}
28 changes: 28 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 @@ -12,6 +12,9 @@ import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import java.time.Instant

/** When a person last flew; see [FlightDao.observeLastFlights]. */
data class PersonLastFlight(val personId: String, val lastFlight: Instant)

/** A person with their travel documents. */
data class PersonWithDocuments(
@Embedded val person: PersonEntity,
Expand Down Expand Up @@ -51,6 +54,16 @@ interface PersonDao {
@Upsert
suspend fun upsertAll(people: List<PersonEntity>)

@Upsert
suspend fun upsertDocuments(documents: List<TravelDocumentEntity>)

/** A CSV import: everyone and their documents land together or not at all. */
@Transaction
suspend fun importPeople(people: List<PersonEntity>, documents: List<TravelDocumentEntity>) {
upsertAll(people)
upsertDocuments(documents)
}

/** Tombstone rather than a hard delete, so the deletion can propagate on export. */
@Query("UPDATE person SET deletedAt = :at, updatedAt = :at WHERE id = :id")
suspend fun softDelete(id: String, at: Instant = Instant.now())
Expand Down Expand Up @@ -187,6 +200,21 @@ interface FlightDao {
@Query("SELECT * FROM flight_person")
suspend fun allMemberships(): List<FlightPersonCrossRef>

/** Who is on each live flight, for suggestions and co-traveller groups. */
@Query("SELECT fp.* FROM flight_person fp JOIN flight f ON f.id = fp.flightId WHERE f.deletedAt IS NULL")
fun observeMemberships(): Flow<List<FlightPersonCrossRef>>

/** Each person's most recent live flight, crew or passenger: "Sort by Recent". */
@Query(
"""
SELECT fp.personId AS personId, MAX(f.departureInstant) AS lastFlight
FROM flight_person fp JOIN flight f ON f.id = fp.flightId
WHERE f.deletedAt IS NULL
GROUP BY fp.personId
""",
)
fun observeLastFlights(): Flow<List<PersonLastFlight>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addPerson(ref: FlightPersonCrossRef)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ private fun FlightEntity.toRecord() = FlightRecord(
departureInstant = departureInstant.toString(), arrivalInstant = arrivalInstant.toString(),
nature = nature, observations = observations, contact = contact, reasonForVisit = reasonForVisit,
aircraftId = aircraftId, responsiblePersonId = responsiblePersonId, tripId = tripId,
legOrder = legOrder,
legOrder = legOrder, chosenDocNumbers = chosenDocNumbers,
updatedAt = updatedAt.toString(), deletedAt = deletedAt?.toString(),
)

Expand All @@ -184,6 +184,6 @@ private fun FlightRecord.toEntity() = FlightEntity(
departureInstant = Instant.parse(departureInstant), arrivalInstant = Instant.parse(arrivalInstant),
nature = nature, observations = observations, contact = contact, reasonForVisit = reasonForVisit,
aircraftId = aircraftId, responsiblePersonId = responsiblePersonId, tripId = tripId,
legOrder = legOrder,
legOrder = legOrder, chosenDocNumbers = chosenDocNumbers,
updatedAt = Instant.parse(updatedAt), deletedAt = deletedAt?.let(Instant::parse),
)
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ data class FlightEntity(
val responsiblePersonId: String? = null,
val tripId: String? = null,
val legOrder: Int = 0,
/**
* Travel documents picked by hand for this flight, by document number, for
* people who carry more than one. Null is "automatic" for everyone, as on
* iOS (`Flight.chosenDocNumbers`). Added in schema version 2.
*/
val chosenDocNumbers: List<String>? = null,
val updatedAt: Instant = Instant.now(),
val deletedAt: Instant? = null,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package aero.flyfun.forms.data

import aero.flyfun.forms.logic.FlightPeople
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.time.Instant

/** Aircraft, flights and the people on them. */
Expand Down Expand Up @@ -29,6 +32,12 @@ interface FlightRepository {

/** Matched the way FPL import normalises: dashes stripped, uppercased. */
suspend fun aircraftByRegistration(registration: String): AircraftEntity?

/** Each person's most recent live flight, by person id. */
fun observeLastFlights(): Flow<Map<String, Instant>>

/** Who was on each live flight, crew in seat order: suggestions and co-traveller groups. */
fun observeFlightPeople(): Flow<List<FlightPeople>>
}

class RoomFlightRepository(
Expand Down Expand Up @@ -72,4 +81,22 @@ class RoomFlightRepository(

override suspend fun aircraftByRegistration(registration: String): AircraftEntity? =
aircraftDao.byNormalisedRegistration(registration.replace("-", "").uppercase())

override fun observeLastFlights(): Flow<Map<String, Instant>> =
flights.observeLastFlights().map { rows -> rows.associate { it.personId to it.lastFlight } }

override fun observeFlightPeople(): Flow<List<FlightPeople>> =
combine(flights.observeAll(), flights.observeMemberships()) { all, memberships ->
val byFlight = memberships.groupBy { it.flightId }
all.map { flight ->
val onBoard = byFlight[flight.id].orEmpty().sortedBy { it.seatOrder }
FlightPeople(
flightId = flight.id,
departure = flight.departureInstant,
aircraftId = flight.aircraftId,
crew = onBoard.filter { it.role == FlightRole.CREW }.map { it.personId },
passengers = onBoard.filter { it.role == FlightRole.PASSENGER }.map { it.personId },
)
}
}
}
Loading
Loading