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 @@ -9,6 +9,11 @@ import dev.stapler.stelekit.platform.PlatformFileSystem
import dev.stapler.stelekit.platform.PlatformSettings
import dev.stapler.stelekit.platform.SteleKitContext
import dev.stapler.stelekit.platform.WriteBehindQueue
import dev.stapler.stelekit.platform.measurement.MeasurementDeviceRegistry
import dev.stapler.stelekit.platform.measurement.ble.KableBleScanner
import dev.stapler.stelekit.platform.sensor.AndroidCameraProvider
import dev.stapler.stelekit.platform.sensor.ARCoreDepthProvider
import dev.stapler.stelekit.platform.sensor.SensorModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -41,6 +46,9 @@ class SteleKitApplication : Application() {
SteleKitContext.init(this)
DriverFactory.setContext(this)
CredentialStore.init(this)
SensorModule.cameraProvider = AndroidCameraProvider(applicationContext)
SensorModule.depthSensorProvider = ARCoreDepthProvider(applicationContext)
MeasurementDeviceRegistry.register(KableBleScanner(applicationContext))
fileSystem = PlatformFileSystem().apply { init(applicationContext) }
// Activate write-behind when MANAGE_EXTERNAL_STORAGE is not granted.
// Direct access (when granted) is faster than write-behind and makes it unnecessary.
Expand Down
25 changes: 25 additions & 0 deletions kmp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,21 @@ kotlin {
// Encrypted SharedPreferences for API key storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")

// ExifInterface — EXIF orientation correction for camera-captured images
implementation("androidx.exifinterface:exifinterface:1.3.7")

// ARCore Depth API — optional AR depth sensing (Story 8.5)
// required=false in AndroidManifest so the app installs on non-AR devices.
implementation("com.google.ar:core:1.46.0")

// CameraX — live camera capture (Story 9.2)
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
// ProcessLifecycleOwner — needed by AndroidCameraProvider; camera-lifecycle pulls
// this transitively but we declare it explicitly to guarantee compile-time resolution.
implementation("androidx.lifecycle:lifecycle-process:2.9.1")

// On-device LLM via Gemini Nano (Pixel 9+ and AICore-enabled OEM flagships)
implementation("com.google.mlkit:genai-prompt:1.0.0-beta2")

Expand All @@ -208,6 +223,13 @@ kotlin {
// WorkManager — periodic background git sync
implementation("androidx.work:work-runtime-ktx:2.9.1")

// Kable — Kotlin coroutine BLE for Android (GATT scanning and peripheral management)
implementation("com.juul.kable:core:0.32.0")

// ONNX Runtime — monocular depth inference (Depth Anything V2 ViT-S)
// Model (~100MB) is downloaded on first use via DepthModelDownloader, not bundled.
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.20.0")

// JGit 5.13.x — Android git operations (Android-safe; Java 11 APIs with desugaring)
implementation("org.eclipse.jgit:org.eclipse.jgit:5.13.3.202401111512-r")
// JGit SSH/JSch integration module (provides JschConfigSessionFactory)
Expand Down Expand Up @@ -262,6 +284,9 @@ kotlin {

// Ktor engine for iOS (used by coil-network-ktor3)
implementation("io.ktor:ktor-client-darwin:3.1.3")

// Kable — Kotlin coroutine BLE for iOS/Apple targets (CoreBluetooth wrapper)
implementation("com.juul.kable:core:0.32.0")
}
}

Expand Down
56 changes: 56 additions & 0 deletions kmp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- GPS tagging for image annotations (Story 8.2.4).
Required by AndroidMotionSensorProvider to request location updates.
Requested at runtime before MotionSensorProvider.startSensing() is called;
the provider gracefully falls back to null GPS fields if denied. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

<!-- Camera capture (Story 9.2).
CAMERA is a runtime permission requested before AndroidCameraProvider.capturePhoto().
required=false so the app installs on devices without a camera (tablets, emulators). -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />

<!-- ARCore Depth API (Story 8.5).
required=false — ARCore depth is optional; the CalibrationFallbackChain degrades
gracefully to EXIF/ML on devices that lack AR hardware or the ARCore APK.
The meta-data tag in <application> tells Google Play that ARCore is optional,
allowing the app to install on all devices (not just AR-capable ones). -->
<uses-feature android:name="android.hardware.camera.ar" android:required="false" />

<!-- BLE Laser Rangefinder permissions (Story 5.2.3, Epic 5).
BLUETOOTH_SCAN + BLUETOOTH_CONNECT: required on API 31+ for BLE scanning and GATT.
BLUETOOTH_ADVERTISE: declared for completeness (not used by scanner role).
All three are runtime permissions on API 31+ — requested before KableBleScanner.scan(). -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- Legacy BLE permissions for API < 31 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />

<!-- BLE hardware feature declaration — marks the feature as not required so the app
installs on non-BLE devices (BLE features degrade gracefully to keyboard mode). -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />

<!-- Foreground service permission required for BLE connection lifecycle on API 31+. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />

<!-- USB host feature for USB serial (OTG) fallback (Story 5.6).
Declared as not required so the app installs on non-OTG devices. -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />

<application>
<!-- ARCore optional — allows Google Play to show an "Install ARCore" prompt on
AR-capable devices without blocking installation on non-AR devices. -->
<meta-data android:name="com.google.ar.core" android:value="optional" />

<!-- Benchmark-only FileProvider: serves cacheDir files via ContentResolver to measure
Binder IPC overhead vs direct file I/O. Declared here (kmp androidMain) so the
provider is present in the kmp instrumented-test APK with the correct ${applicationId}
Expand All @@ -14,6 +62,14 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/bench_file_paths" />
</provider>

<!-- BLE measurement foreground service (Story 5.2.4).
foregroundServiceType="connectedDevice" required on API 31+ for BLE GATT connections
initiated in the background. Auto-dismisses notification on device disconnect. -->
<service
android:name="dev.stapler.stelekit.platform.measurement.ble.AndroidMeasurementForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Tyler Stapler
// SPDX-License-Identifier: Elastic-2.0

package dev.stapler.stelekit.platform.google

import android.content.Intent
import android.net.Uri
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import dev.stapler.stelekit.error.DomainError
import dev.stapler.stelekit.platform.SteleKitContext

/**
* Android implementation of [GoogleAuthManager].
*
* Uses a WebView/Custom Tab OAuth 2.0 flow targeting:
* https://accounts.google.com/o/oauth2/auth
*
* Credential Manager (play-services-auth) is NOT yet available in the project dependencies.
* This stub opens the OAuth consent screen in the system browser (Custom Tab or fallback).
* The redirect URI must be registered in the Google Cloud Console as:
* com.stelekit.app:/oauth2redirect
*
* TODO (Story 7.2): When `credentials` + `credentials-play-services-auth` deps are added
* to build.gradle.kts, replace this with GetGoogleIdOption Credential Manager flow.
*
* NOTE: Token exchange (auth code → access/refresh tokens) requires a server-side endpoint
* or a native app client secret. For now this implementation is a structural stub that
* opens the browser. The token exchange is wired once a client_id is configured.
*/
class AndroidGoogleAuthManager(
private val tokenStore: GoogleTokenStore,
private val clientId: String = "",
) : GoogleAuthManager {

companion object {
val SCOPES = listOf(
"https://www.googleapis.com/auth/drive.file",
"email",
"profile",
)
const val REDIRECT_URI = "com.stelekit.app:/oauth2redirect"
}

override suspend fun authenticate(): Either<DomainError, String> {
if (clientId.isBlank()) {
return DomainError.NetworkError.HttpError(
statusCode = 400,
message = "Google OAuth client ID not configured. Set WEB_CLIENT_ID in build config.",
).left()
}

val scopeString = SCOPES.joinToString(" ")
val authUrl = "https://accounts.google.com/o/oauth2/auth" +
"?client_id=$clientId" +
"&redirect_uri=${Uri.encode(REDIRECT_URI)}" +
"&response_type=code" +
"&scope=${Uri.encode(scopeString)}" +
"&access_type=offline" +
"&prompt=consent"

return try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
SteleKitContext.context.startActivity(intent)
// Browser launched — actual token storage happens via deep-link callback.
// Return a pending state; real token saving occurs in the deep-link handler.
DomainError.NetworkError.HttpError(
statusCode = 202,
message = "OAuth flow initiated in browser. Complete sign-in to continue.",
).left()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
DomainError.NetworkError.HttpError(
statusCode = -1,
message = "Failed to launch OAuth browser: ${e.message}",
).left()
}
}

override suspend fun signOut() {
tokenStore.clearTokens()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) 2026 Tyler Stapler
// SPDX-License-Identifier: Elastic-2.0

package dev.stapler.stelekit.platform.google

import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dev.stapler.stelekit.platform.SteleKitContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* Android implementation of [GoogleTokenStore] using [EncryptedSharedPreferences]
* backed by Android Keystore (AES256-GCM keys).
*
* SECURITY: If [EncryptedSharedPreferences] fails to initialize (corrupted Keystore,
* missing hardware), this implementation throws rather than falling back to plain
* SharedPreferences — per the ADR-003 security requirement.
*/
class AndroidGoogleTokenStore : GoogleTokenStore {

private val prefs by lazy {
try {
val masterKey = MasterKey.Builder(SteleKitContext.context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
SteleKitContext.context,
"stelekit_google_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Do NOT fall back to plain SharedPreferences for tokens — fail loudly.
Log.e(TAG, "EncryptedSharedPreferences initialization failed. Google tokens cannot be stored securely.", e)
throw IllegalStateException(
"Android Keystore unavailable. Cannot store OAuth tokens securely. " +
"Google account features require a device with hardware-backed Keystore.",
e,
)
}
}

override suspend fun saveTokens(
accessToken: String,
refreshToken: String,
expiresAt: Long,
) = withContext(Dispatchers.IO) {
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.putLong(KEY_EXPIRES_AT, expiresAt)
.apply()
}

override suspend fun getAccessToken(): String? = withContext(Dispatchers.IO) {
prefs.getString(KEY_ACCESS_TOKEN, null)
}

override suspend fun getRefreshToken(): String? = withContext(Dispatchers.IO) {
prefs.getString(KEY_REFRESH_TOKEN, null)
}

override suspend fun getExpiresAt(): Long? = withContext(Dispatchers.IO) {
if (!prefs.contains(KEY_EXPIRES_AT)) null
else prefs.getLong(KEY_EXPIRES_AT, 0L)
}

override suspend fun clearTokens() = withContext(Dispatchers.IO) {
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_EXPIRES_AT)
.apply()
}

override suspend fun isAuthenticated(): Boolean = withContext(Dispatchers.IO) {
prefs.contains(KEY_ACCESS_TOKEN) && prefs.getString(KEY_ACCESS_TOKEN, null) != null
}

private companion object {
private const val TAG = "AndroidGoogleTokenStore"
private const val KEY_ACCESS_TOKEN = "google_access_token"
private const val KEY_REFRESH_TOKEN = "google_refresh_token"
private const val KEY_EXPIRES_AT = "google_expires_at"
}
}
Loading
Loading