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
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,5 +377,18 @@ app or clearing its data deletes it. Recording has no switch of its own — it
follows the background-location grant — and the map's 軌跡 layer, which shows
it, says all of this in its sheet; its clear goes through the native side,
never a file delete from Dart.

Secrets the user hands the app — today only the API key of their own AI
service, for the AI assistant — are the one thing persisted outside SQLite,
and on purpose: `dpip.db` sits in application-support, which iOS copies into
backups, and the AI page promises the key does not leave the phone. They go
through `SecretStore` (`core/platform/secret_store.dart`), keyed by a
`SecretKey` enum for the same reason settings take a `SettingKey`: an iOS
Keychain item with `AfterFirstUnlockThisDeviceOnly`, and on Android an
AES-GCM key generated in the Android Keystore with only the ciphertext on
disk, in `noBackupFilesDir`.
The AI service's base URL and model are ordinary settings. The AI
conversation itself is not persisted at all — it lives in memory and ends
with the process.
- Every file starts with a doc comment; one public declaration = one clear
responsibility.
3 changes: 3 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, BackgroundExecutionChannel.NAME)
.setMethodCallHandler(BackgroundExecutionChannel(applicationContext))

MethodChannel(messenger, SecretStoreChannel.NAME)
.setMethodCallHandler(SecretStoreChannel(applicationContext))

// Activity-scoped: the keep-awake window flag belongs to this window.
MethodChannel(messenger, ScreenWakeChannel.NAME)
.setMethodCallHandler(ScreenWakeChannel(this))
Expand Down
150 changes: 150 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/SecretStoreChannel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.exptech.dpip

import android.content.Context
import android.os.Handler
import android.os.Looper
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.util.concurrent.Executors
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

/**
* Secrets the user hands the app — today the AI chat's API key.
*
* The AES-GCM key is generated inside the Android Keystore and never leaves
* it; this class only ever holds ciphertext, written to `noBackupFilesDir` so
* that even with backups enabled the file would stay on this device. Each file
* is the 12-byte IV followed by the ciphertext and its tag.
*
* A file the Keystore can no longer decrypt (its key was invalidated, e.g. by
* removing the lock screen) is deleted and read back as absent: there is
* nothing else to be done with it, and the user simply enters the key again.
*
* Keystore work runs off the main thread; results are posted back to it.
*/
class SecretStoreChannel(context: Context) : MethodChannel.MethodCallHandler {

companion object {
const val NAME = "com.exptech.dpip/secret_store"
private const val KEY_ALIAS = "dpip_secret_store_v1"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_BYTES = 12
private const val TAG_BITS = 128

/** Secret names are a fixed set on the Dart side; this keeps a bad
* one from naming a path outside the secrets directory. */
private val SAFE_NAME = Regex("^[A-Za-z0-9._-]{1,64}$")
}

private val directory = File(context.noBackupFilesDir, "secrets")
private val worker = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("key")
if (name == null || !SAFE_NAME.matches(name)) {
result.error("bad_key", "missing or invalid key name", null)
return
}
when (call.method) {
"read" -> run(result) { read(name) }
"write" -> {
val value = call.argument<String>("value")
if (value == null) {
result.error("bad_value", "missing value", null)
return
}
run(result) { write(name, value); null }
}
"delete" -> run(result) { fileFor(name).delete(); null }
else -> result.notImplemented()
}
}

private fun run(result: MethodChannel.Result, work: () -> String?) {
worker.execute {
try {
val value = work()
main.post { result.success(value) }
} catch (error: Exception) {
// The class name only: a message could quote what failed.
main.post {
result.error("failed", error.javaClass.simpleName, null)
}
}
}
}

private fun fileFor(name: String) = File(directory, "$name.bin")

private fun read(name: String): String? {
val file = fileFor(name)
if (!file.exists()) return null
val bytes = file.readBytes()
if (bytes.size <= IV_BYTES) {
file.delete()
return null
}
return try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(
Cipher.DECRYPT_MODE,
existingKey() ?: run { file.delete(); return null },
GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES),
)
String(
cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES),
Charsets.UTF_8,
)
} catch (error: GeneralSecurityException) {
file.delete()
null
}
}

private fun write(name: String, value: String) {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, existingKey() ?: newKey())
val sealed = cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
directory.mkdirs()
// Write beside, then rename: a crash mid-write must not leave a file
// that fails its tag and erases the previous key on the next read.
val staging = File(directory, "$name.tmp")
staging.writeBytes(sealed)
if (!staging.renameTo(fileFor(name))) {
staging.delete()
throw IllegalStateException("rename failed")
}
}

private fun existingKey(): SecretKey? {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
return store.getKey(KEY_ALIAS, null) as SecretKey?
}

private fun newKey(): SecretKey {
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore",
)
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()
}
}
16 changes: 16 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
| `TremStationRepositoryImpl.refresh` | `/resource/station` | `coreStatic` | `static.core-{tyo1,tnn1}.exptech.dev` |
| `EarthquakeApi.getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
| `EarthquakeApi.getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
| `DpipMcpToolHost`(AI 對話的工具) | `/mcp`(**POST**,JSON-RPC) | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |

> **地震報告 list(v2)query:** `limit`/`page`、`sort`/`order`
> (`time`\|`intensity`\|`magnitude`\|`depth` × `asc`\|`desc`)、震度/規模/深度
Expand All @@ -76,6 +77,13 @@
> `getRtsAt` 的 `{sec}` 必須是 10 位數的 Unix 秒;剛過去的一兩分鐘可能只有一個
> 區域歸檔好(實測 now−30 s:tnn1 200、tyo1 404),所以 404 是常態而非故障。

> **MCP `/mcp`** 是唯讀的 DPIP MCP 伺服器(Streamable HTTP,無狀態、不發
> session id),AI 對話把它的工具交給使用者自己的 AI 服務。App 只送
> `initialize`、`notifications/initialized`、`tools/list`、`tools/call`;回應是
> 一則 `text/event-stream` 的 `message` 事件(也接受純 JSON)。兩個區域都有
> (2026-10-22 實測 `x-served-by: core-tnn1` / `core-tyo1`)。AI 對話頁**顯示**給
> 使用者自用的網址是裸主機 `https://api.core.exptech.dev/mcp`,App 自己不呼叫它。

> **測站清單 `/resource/station` 是 CSV**(`Content-Type` 卻標成 JSON):
> `loc_code,id,lat,lon,floor,code,net,time,work`,`id` 是 RTS 用的 hex 裝置 id、
> `code` 是鄉鎮代碼。每個區域的 nginx 各自產生弱 ETag(同檔不同值),帶
Expand Down Expand Up @@ -341,8 +349,16 @@ ETag 重新驗證。
| `ServerStatusApi.getStatus` | `https://status.exptech.dev/api/ds/query`(**POST**,Grafana datasource query;伺服器狀態頁) |
| `CloudflareStatusApi.getComponents` | `https://www.cloudflarestatus.com/api/v2/components.json`(Cloudflare 元件狀態) |
| `HasteApi.upload` | `https://haste.exptech.dev/api/pastes`(**POST**,上傳 App 日誌;回應的 `key` 組成 `https://haste.exptech.dev/<key>`) |
| `OpenAiChatModel.complete` | 使用者自己設定的 AI 服務:`{baseUrl}/chat/completions`(**POST**,`stream: true`,OpenAI 相容格式;走 `ApiClient.streamAbsolute`) |
| `AiSettingsRepositoryImpl.listModels` | 同一個 AI 服務的 `{baseUrl}/models`(**GET**,走 `streamAbsolute`) |
| `MlIntensityService._download` | `https://exptechtw.github.io/TREM-Lite/models/intensity_ml_v1.onnx`,失敗再試 `https://cdn.jsdelivr.net/gh/ExpTechTW/TREM-Lite@main/packages/core/static/models/intensity_ml_v1.onnx`(ML v1 震度模型,14.7 MB、gzip 傳輸約 2.3 MB) |

> **AI 服務是使用者自己的,不是 ExpTech 的。** 網址、模型與金鑰由使用者在 AI
> 設定頁填入;金鑰只存在系統金鑰庫(`SecretStore`),以 `Authorization: Bearer`
> 送給該服務。這兩個呼叫走 `streamAbsolute` 而不是 `getAbsolute`:串流回應不進
> ETag 快取,而這些回應取決於使用者的金鑰。只允許 `https://`(`localhost` 除
> 外)。日誌只記狀態碼與錯誤種類,不記網址、金鑰或對話內容。

> **ML v1 模型目前沒有 ExpTech 主機提供**(`static.core-{tnn1,tyo1}` 都是 404),
> 所以從 TREM-Lite 發布的位置下載,並以 SHA-256 釘住檔案:jsDelivr 跟著
> `main`,同名重訓的模型會被拒絕。第一次開強震監視器時下載,gzip 壓縮後存在
Expand Down
8 changes: 8 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
AA0000000000000000000C02 /* CompassPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000C01 /* CompassPlugin.swift */; };
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; };
AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; };
SK0S00000000000000000002 /* SecretStorePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = SK0S00000000000000000001 /* SecretStorePlugin.swift */; };
SK0S00000000000000000004 /* SecretStorePluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SK0S00000000000000000003 /* SecretStorePluginTests.swift */; };
AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; };
AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000G01 /* LocationTrackStore.swift */; };
AA0000000000000000000H02 /* NetworkPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000H01 /* NetworkPlugin.swift */; };
Expand Down Expand Up @@ -180,6 +182,8 @@
AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = "<group>"; };
SK0S00000000000000000001 /* SecretStorePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretStorePlugin.swift; sourceTree = "<group>"; };
SK0S00000000000000000003 /* SecretStorePluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretStorePluginTests.swift; sourceTree = "<group>"; };
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = "<group>"; };
AA0000000000000000000H01 /* NetworkPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPlugin.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -429,6 +433,7 @@
27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */,
NL0G00000000000000000003 /* NativeLogTests.swift */,
LR0S00000000000000000003 /* LocationReportSenderTests.swift */,
SK0S00000000000000000003 /* SecretStorePluginTests.swift */,
NSE000000000000000000011 /* NotificationDeliveryBreadcrumbTests.swift */,
270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */,
270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */,
Expand Down Expand Up @@ -497,6 +502,7 @@
AA0000000000000000000C01 /* CompassPlugin.swift */,
AA0000000000000000000H01 /* NetworkPlugin.swift */,
AA0000000000000000000E01 /* ScreenWakePlugin.swift */,
SK0S00000000000000000001 /* SecretStorePlugin.swift */,
AA0000000000000000000G01 /* LocationTrackStore.swift */,
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */,
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */,
Expand Down Expand Up @@ -843,6 +849,7 @@
27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */,
NL0G00000000000000000004 /* NativeLogTests.swift in Sources */,
LR0S00000000000000000004 /* LocationReportSenderTests.swift in Sources */,
SK0S00000000000000000004 /* SecretStorePluginTests.swift in Sources */,
NSE000000000000000000012 /* NotificationDeliveryBreadcrumbTests.swift in Sources */,
270FCEF5305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift in Sources */,
27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */,
Expand All @@ -865,6 +872,7 @@
AA0000000000000000000C02 /* CompassPlugin.swift in Sources */,
AA0000000000000000000H02 /* NetworkPlugin.swift in Sources */,
AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */,
SK0S00000000000000000002 /* SecretStorePlugin.swift in Sources */,
AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */,
AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */,
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import UserNotifications
MapCachePlugin.register(with: registry.registrar(forPlugin: "MapCachePlugin")!)
StorageScanPlugin.register(with: registry.registrar(forPlugin: "StorageScanPlugin")!)
ScreenWakePlugin.register(with: registry.registrar(forPlugin: "ScreenWakePlugin")!)
SecretStorePlugin.register(with: registry.registrar(forPlugin: "SecretStorePlugin")!)
ApnsTokenPlugin.register(with: registry.registrar(forPlugin: "ApnsTokenPlugin")!)
BackgroundLocationPlugin.register(
with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!)
Expand Down
108 changes: 108 additions & 0 deletions ios/Runner/SecretStorePlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Flutter
import Foundation
import Security

/// Secrets the user hands the app — today the AI chat's API key.
///
/// One Keychain generic-password item per secret, readable after the first
/// unlock and **only on this device**: `ThisDeviceOnly` keeps the item out of
/// any backup restored onto another phone, which is what lets the app say the
/// key never leaves this one.
///
/// Errors report the `OSStatus` only, never the value.
public class SecretStorePlugin: NSObject, FlutterPlugin {
static let service = "com.exptech.dpip.secret-store"

public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.exptech.dpip/secret_store",
binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(SecretStorePlugin(), channel: channel)
}

public func handle(
_ call: FlutterMethodCall, result: @escaping FlutterResult
) {
guard let args = call.arguments as? [String: Any],
let key = args["key"] as? String, !key.isEmpty
else {
result(FlutterError(code: "bad_key", message: "missing key", details: nil))
return
}
switch call.method {
case "read":
switch SecretStorePlugin.read(key) {
case .success(let value): result(value)
case .failure(let status): result(SecretStorePlugin.error(status))
}
case "write":
guard let value = args["value"] as? String else {
result(FlutterError(code: "bad_value", message: "missing value", details: nil))
return
}
let status = SecretStorePlugin.write(key, value)
result(status == errSecSuccess ? nil : SecretStorePlugin.error(status))
case "delete":
let status = SecretStorePlugin.delete(key)
result(status == errSecSuccess ? nil : SecretStorePlugin.error(status))
default:
result(FlutterMethodNotImplemented)
}
}

static func base(_ key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
}

/// The saved value, nil when there is none.
static func read(_ key: String) -> Result<String?, OSStatusError> {
var query = base(key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
switch status {
case errSecSuccess:
guard let data = item as? Data else { return .success(nil) }
return .success(String(data: data, encoding: .utf8))
case errSecItemNotFound:
return .success(nil)
default:
return .failure(OSStatusError(status: status))
}
}

/// Replaces any earlier value. Delete-then-add rather than update, so the
/// accessibility class is always the one set here, even for an item an
/// older build might have written differently.
static func write(_ key: String, _ value: String) -> OSStatus {
_ = delete(key)
var item = base(key)
item[kSecValueData as String] = Data(value.utf8)
item[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
return SecItemAdd(item as CFDictionary, nil)
}

/// Removes the value; absent already counts as success.
static func delete(_ key: String) -> OSStatus {
let status = SecItemDelete(base(key) as CFDictionary)
return status == errSecItemNotFound ? errSecSuccess : status
}

static func error(_ status: OSStatus) -> FlutterError {
FlutterError(code: "keychain", message: "OSStatus \(status)", details: nil)
}

static func error(_ failure: OSStatusError) -> FlutterError {
error(failure.status)
}
}

struct OSStatusError: Error {
let status: OSStatus
}
Loading
Loading