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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .github/workflows/flutter_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
run: flutter --version
- name: Run generator
run: dart run build_runner build --delete-conflicting-outputs
- name: Check historical database schema contracts
run: dart run tool/check_database_schema.dart
- name: Check generated Dart policy
run: dart run tool/check_generated_dart_policy.dart
- name: Check local-only product boundary
Expand Down
14 changes: 13 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ _Avoid_: Offline-first, local cache mode, standalone mode
The single installation-bound, non-identifying profile that owns Schedules, Places, Preparations, outcomes, and preferences.
_Avoid_: Account, login, member, OnTime User

**Schedule Draft**:
The editable contents of a Schedule while the user is creating or changing it, before those edits are saved or discarded.
_Avoid_: Saved Schedule, automatic backup, Preparation Run

**Unsaved Schedule Changes**:
Changes in a Schedule Draft that differ from the editing starting point accepted by the user and have not been saved. Changes already saved remain saved when notification delivery setup is still pending.
_Avoid_: Validation error, pending notification, backup freshness

**Schedule Outcome**:
The final local result of completing one Schedule as On Time, Late, or Abnormal.
_Avoid_: Server result, analytics event, transient completion screen
Expand Down Expand Up @@ -202,9 +210,13 @@ The sum of a Schedule's Preparation step durations, excluding move time and Sche
_Avoid_: Total duration, travel time, buffer time

**Schedule Spare Time**:
A user buffer before a Schedule's appointment time, separate from travel time and Preparation Duration.
A user buffer before a Schedule's appointment time, separate from travel time and Preparation Duration. A Schedule with no assigned buffer has zero spare time; changing the profile default does not change that Schedule's buffer.
_Avoid_: Preparation time, move time

**Default Schedule Spare Time**:
The profile's suggested buffer used to initialize a new Schedule draft. It does not replace an absent buffer on an existing or restored Schedule.
_Avoid_: Inherited schedule buffer, automatic buffer override

**Default Preparation**:
The user's fallback Preparation for new Schedules.
_Avoid_: Base preparation, global preparation
Expand Down
2 changes: 2 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ analyzer:
exclude:
- build/**
- widgetbook/**
# Frozen before-change manifests are evidence, not a runnable package.
- plans/audit-2026-09-23/artifacts/d05/q8-validation/dependency-before/**

linter:
# The lint rules applied to this project can be customized in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ object AlarmLaunchPayload {
if (id.isBlank() || id.length > 512 || id.any { it.code < 32 || it.code == 127 }) {
return null
}
return mapOf(
val identity = source["storeIncarnation"]
if (source.containsKey("storeIncarnation") &&
(identity !is String || !Regex("^[a-fA-F0-9-]{32,36}$").matches(identity))) {
return null
}
return mutableMapOf(
"type" to "schedule_alarm",
"scheduleId" to id,
"alarmLaunchPayloadVersion" to "9",
"alarmLaunchPayloadVersion" to "10",
"promptVariant" to "alarm",
)
).apply { if (identity is String) put("storeIncarnation", identity) }
}

/** Provider display fields are never forwarded to Flutter as a route. */
Expand Down
363 changes: 265 additions & 98 deletions android/app/src/main/kotlin/club/devkor/ontime/BackupExportPlugin.kt

Large diffs are not rendered by default.

244 changes: 244 additions & 0 deletions android/app/src/main/kotlin/club/devkor/ontime/BackupImportPlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package club.devkor.ontime

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import java.io.InputStream
import java.io.IOException
import java.util.UUID
import java.util.concurrent.Executors

/** Narrow backup reader. The URI and its transient permission never cross Dart. */
class BackupImportPlugin : FlutterPlugin, ActivityAware,
PluginRegistry.ActivityResultListener, MethodChannel.MethodCallHandler {
private lateinit var context: Context
private var channel: MethodChannel? = null
private var binding: ActivityPluginBinding? = null
private val main = Handler(Looper.getMainLooper())
private var active: Attempt? = null
private var lastClosed: String? = null

private class Attempt(val id: String, val requestCode: Int) {
@Volatile var cancelled = false
@Volatile var stream: InputStream? = null
var pick: MethodChannel.Result? = null
var busy = false
var closing = false
var closeFailure = false
var closeResult: MethodChannel.Result? = null
var consumed = 0L
var eof = false
var picked = false
}
companion object {
private const val LIMIT = 72L * 1024 * 1024
private const val CHUNK = 65536
// Reuse only after Android has delivered that selection's terminal
// callback. Cancelled pickers with a late callback keep their slot.
private val pendingRequests = mutableSetOf<Int>()
@Synchronized private fun reserveRequest(): Int? {
val code = (18000..23999).firstOrNull { it !in pendingRequests } ?: return null
pendingRequests.add(code)
return code
}
@Synchronized private fun releaseRequest(code: Int) { pendingRequests.remove(code) }
private val reads = Executors.newSingleThreadExecutor()
// Cancellation cannot be queued behind a blocking provider read.
private val closes = Executors.newSingleThreadExecutor()
}

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "ontime/backup_import")
channel?.setMethodCallHandler(this)
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "begin") {
if (active != null) { fail(result, "import_busy"); return }
val code = reserveRequest()
if (code == null) { fail(result, "import_busy"); return }
val attempt = Attempt(UUID.randomUUID().toString(), code)
active = attempt
result.success(attempt.id)
return
}
val id = call.argument<String>("handle")
if (call.method == "close" && id != null && id == lastClosed) {
result.success(true); return
}
val attempt = active
if (attempt == null || id != attempt.id) { fail(result); return }
when (call.method) {
"pick" -> {
val activity = binding?.activity
if (activity == null || attempt.picked || attempt.cancelled) { fail(result); return }
attempt.picked = true
attempt.pick = result
try {
activity.startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}, attempt.requestCode)
} catch (_: Exception) { releaseRequest(attempt.requestCode); attempt.pick = null; fail(result) }
}
"read" -> {
val rawMaximum = call.argument<Any?>("maxBytes")
val maximum = when (rawMaximum) {
is Int -> rawMaximum.toLong()
is Long -> rawMaximum
else -> null
}
if (maximum == null || maximum !in 1..CHUNK.toLong() || attempt.busy ||
attempt.cancelled || attempt.stream == null || attempt.pick != null) {
fail(result); return
}
if (attempt.eof) {
result.success(mapOf("bytes" to ByteArray(0), "eof" to true)); return
}
attempt.busy = true
val size = minOf(maximum, LIMIT - attempt.consumed + 1L).toInt()
reads.execute {
var bytes: ByteArray? = null
var eof = false
var error: String? = null
try {
if (attempt.cancelled) throw IOException()
val buffer = ByteArray(size)
val count = attempt.stream!!.read(buffer)
when {
count < 0 -> { bytes = ByteArray(0); eof = true }
count == 0 -> error = "import_io"
count.toLong() > LIMIT - attempt.consumed -> error = "import_limit"
else -> bytes = if (count == size) buffer else buffer.copyOf(count)
}
} catch (_: Exception) { error = "import_io" }
main.post {
attempt.busy = false
if (attempt.cancelled) fail(result, "import_cancelled")
else if (error != null) fail(result, error!!)
else {
attempt.consumed += bytes!!.size
attempt.eof = eof
result.success(mapOf("bytes" to bytes, "eof" to eof))
}
finishClose(attempt)
}
}
}
"close" -> close(attempt, result)
else -> result.notImplemented()
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode !in 18000..23999) return false
releaseRequest(requestCode)
val attempt = active
if (attempt == null || requestCode != attempt.requestCode) return false
val reply = attempt.pick ?: return true
attempt.pick = null
if (attempt.cancelled) { fail(reply, "import_cancelled"); return true }
val uri = data?.data
if (resultCode != Activity.RESULT_OK) { reply.success(null); return true }
if (uri == null) { fail(reply); return true }
attempt.busy = true
reads.execute {
var length: Long? = null
var failure: String? = null
try {
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
val column = cursor.getColumnIndex(OpenableColumns.SIZE)
if (column >= 0 && cursor.moveToFirst() && !cursor.isNull(column)) {
val size = cursor.getLong(column)
if (size >= 0) length = size // Unknown/negative is not an allocation size.
}
}
if (length != null && length!! > LIMIT) failure = "import_limit"
else if (!attempt.cancelled) {
val opened = context.contentResolver.openInputStream(uri) ?: throw IOException()
attempt.stream = opened
// close() may have raced the provider's blocking open call.
if (attempt.cancelled) {
opened.close()
attempt.stream = null
}
}
} catch (_: Exception) { failure = "import_io" }
main.post {
attempt.busy = false
if (attempt.cancelled) fail(reply, "import_cancelled")
else if (failure != null) fail(reply, failure!!)
else reply.success(mapOf("length" to length))
finishClose(attempt)
}
}
return true
}

private fun close(attempt: Attempt, result: MethodChannel.Result?) {
if (attempt.closeResult != null) { if (result != null) fail(result, "import_busy"); return }
attempt.closeResult = result
attempt.cancelled = true
if (!attempt.picked) releaseRequest(attempt.requestCode)
attempt.pick?.let { fail(it, "import_cancelled") }
attempt.pick = null
try { binding?.activity?.finishActivity(attempt.requestCode) } catch (_: Exception) { }
if (attempt.closing) return
attempt.closing = true
attempt.closeFailure = false
closes.execute {
try { attempt.stream?.close(); attempt.stream = null }
catch (_: Exception) { attempt.closeFailure = true }
main.post { attempt.closing = false; finishClose(attempt) }
}
}

private fun finishClose(attempt: Attempt) {
if (!attempt.cancelled || attempt.busy || attempt.closing) return
// An open that returned after the first close needs another actual close.
if (attempt.stream != null && !attempt.closeFailure) {
val reply = attempt.closeResult
attempt.closeResult = null
close(attempt, reply)
return
}
val reply = attempt.closeResult
attempt.closeResult = null
if (attempt.closeFailure) { if (reply != null) fail(reply); return }
if (active === attempt) active = null
lastClosed = attempt.id
reply?.success(true)
}

private fun fail(result: MethodChannel.Result, code: String = "import_io") {
result.error(code, "Backup input could not be read.", null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
this.binding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() = detachActivity()
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = onAttachedToActivity(binding)
override fun onDetachedFromActivity() = detachActivity()
private fun detachActivity() {
active?.let { close(it, null) }
binding?.removeActivityResultListener(this)
binding = null
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
active?.let { close(it, null) }
channel?.setMethodCallHandler(null)
channel = null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ open class MainActivity : FlutterActivity() {
private const val CHANNEL_NAME = "on_time_front/native_alarm"
const val ACTION_SCHEDULE_ALARM = "on_time_front.SCHEDULE_ALARM"
private var launchPayload: Map<String, String>? = null
private val processIdentity = java.util.UUID.randomUUID().toString()
}

override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -39,6 +40,9 @@ open class MainActivity : FlutterActivity() {

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
if (!flutterEngine.plugins.has(BackupImportPlugin::class.java)) {
flutterEngine.plugins.add(BackupImportPlugin())
}
if (!flutterEngine.plugins.has(BackupExportPlugin::class.java)) {
flutterEngine.plugins.add(BackupExportPlugin())
}
Expand All @@ -49,6 +53,7 @@ open class MainActivity : FlutterActivity() {
)
methodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"getProcessIdentity" -> result.success(processIdentity)
"getCapabilities" -> {
val nativeAlarmApproved =
NativeAlarmPolicy.isAndroidFullScreenAlarmApproved()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ class NativeAlarmBootReceiver : BroadcastReceiver() {
if (preparationStartTimeMillis != null) {
extras["preparationStartTime"] = preparationStartTimeMillis.toString()
}
// Preserve only the identity originally issued with this registry record.
// Never stamp a legacy record with the current installation identity.
if (record.has("storeIncarnation") && !record.isNull("storeIncarnation")) {
extras["storeIncarnation"] = record.optString("storeIncarnation")
}
extras["title"] = record.optString("scheduleTitle", "")
extras["body"] = "It is time to get ready."
return NativeAlarmReceiver.alarmPendingIntentForRecord(
Expand Down Expand Up @@ -177,6 +182,11 @@ class NativeAlarmBootReceiver : BroadcastReceiver() {
parseAlarmTime(record.optString("preparationStartTime"))?.let {
extras["preparationStartTime"] = it.toString()
}
// Preserve only the identity originally issued with this registry record.
// Never stamp a legacy record with the current installation identity.
if (record.has("storeIncarnation") && !record.isNull("storeIncarnation")) {
extras["storeIncarnation"] = record.optString("storeIncarnation")
}
extras["title"] = record.optString("scheduleTitle", "")
extras["body"] = "It is time to get ready."
return NativeAlarmReceiver.activityPendingIntentForExtras(
Expand Down
10 changes: 10 additions & 0 deletions build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
builders:
database_schema_contracts:
import: "tool/database_schema_builder.dart"
builder_factories: ["databaseSchemaContracts"]
build_extensions: {".schema.json": [".g.dart"]}
auto_apply: root_package
build_to: source
defaults:
generate_for:
- lib/core/database/schema_contracts.schema.json
17 changes: 5 additions & 12 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,11 @@ class GetUserResponseModel {

### 5. Database Layer with Drift

```dart
@DriftDatabase(tables: [Users, Schedules, Places], daos: [UserDao, ScheduleDao])
class AppDatabase extends _$AppDatabase {
@override
int get schemaVersion => 3;

@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async => await m.createAll(),
);
}
```
`AppDatabase` uses encrypted local schema 4 and eleven owned tables. The checked-in historical contracts independently describe schemas 1–4; the shared current-version and supported-lineage declaration lives in `lib/core/database/schema_contracts.schema.json`. `build_runner` embeds that input in an ignored generated Dart file, and CI validates its digest and the executable DDL.

Normal legacy startup may migrate supported schemas 1/2/3 to 4. Active replacement pairs start with schema 4; candidate validation never implicitly migrates. Before writes, openers require SQLCipher, the existing key, and a matching historical schema. Unknown, partial, future, and nonempty version-zero stores remain preserved for Recovery. Creation requires the initial-store guard or explicit owned candidate authority.

Migration DDL, backfill, schema/foreign-key checks and `user_version` commit in one SQLite transaction. The later Drift version acknowledgement can still fail after a complete commit, so response errors alone do not prove rollback. Foreign-key connection settings remain outside the transaction. See ADR 0023 and the D04 historical fixtures for invariants and evidence scope.

## 🔄 Data Flow

Expand Down
Loading
Loading