Initial commit: VesiscanBasic Android app
Port of MedilightV2 iOS app to Android with Kotlin + Jetpack Compose. Includes BLE connectivity, NIRS/Piezo sensor modes, Rive animations, interactive measurement history charts, and foldable device support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
object KeychainService {
|
||||
private const val FILE_NAME = "medilightv2_secure_prefs"
|
||||
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
FILE_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
fun save(key: String, value: String) {
|
||||
prefs?.edit()?.putString(key, value)?.apply()
|
||||
}
|
||||
|
||||
fun load(key: String): String? = prefs?.getString(key, null)
|
||||
|
||||
fun delete(key: String) {
|
||||
prefs?.edit()?.remove(key)?.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.example.medilightv2android.models.MeasurementRecord
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
class MeasurementHistoryStore private constructor(context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: MeasurementHistoryStore? = null
|
||||
fun getInstance(context: Context): MeasurementHistoryStore =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: MeasurementHistoryStore(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
private const val MAX_RECORDS = 100
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("measurement_history", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
private val key = "measurement_history"
|
||||
|
||||
var records: List<MeasurementRecord>
|
||||
get() {
|
||||
val json = prefs.getString(key, null) ?: return emptyList()
|
||||
return try {
|
||||
val type = object : TypeToken<List<MeasurementRecord>>() {}.type
|
||||
gson.fromJson(json, type)
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
private set(value) {
|
||||
val trimmed = if (value.size > MAX_RECORDS) value.take(MAX_RECORDS) else value
|
||||
prefs.edit().putString(key, gson.toJson(trimmed)).apply()
|
||||
}
|
||||
|
||||
fun addRecord(record: MeasurementRecord) {
|
||||
val current = records.toMutableList()
|
||||
current.add(0, record)
|
||||
records = current
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(key).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.example.medilightv2android.ble.BleManager
|
||||
import com.example.medilightv2android.models.SensorMode
|
||||
|
||||
class MeasurementService private constructor(context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: MeasurementService? = null
|
||||
fun getInstance(context: Context): MeasurementService =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: MeasurementService(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
private val bleManager = BleManager.getInstance(context)
|
||||
|
||||
val isMeasuring = mutableStateOf(false)
|
||||
|
||||
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
|
||||
isMeasuring.value = true
|
||||
when (mode) {
|
||||
SensorMode.NIRS -> performNirsMeasurement(completion)
|
||||
SensorMode.PIEZO -> performPiezoMeasurement(completion)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
|
||||
bleManager.onNirsMcjReceived = { _ ->
|
||||
isMeasuring.value = false
|
||||
completion(true)
|
||||
}
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
|
||||
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
|
||||
bleManager.onPiezoDataReceived = { _ ->
|
||||
isMeasuring.value = false
|
||||
completion(true)
|
||||
}
|
||||
bleManager.sendBurst(freqOption = 2, cycles = 5, delayUs = 100)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
isMeasuring.value = false
|
||||
bleManager.sendPiezoStop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.example.medilightv2android.R
|
||||
import com.example.medilightv2android.models.UrgencyLevel
|
||||
|
||||
class NotificationService private constructor(private val context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: NotificationService? = null
|
||||
fun getInstance(context: Context): NotificationService =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: NotificationService(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
private const val CHANNEL_ID = "bladder_urgency"
|
||||
private const val CHANNEL_NAME = "Bladder Monitoring"
|
||||
}
|
||||
|
||||
private var lastNotifiedUrgency: String? = null
|
||||
|
||||
init {
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Bladder level urgency notifications"
|
||||
enableVibration(true)
|
||||
}
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendUrgencyNotification(urgency: UrgencyLevel, volume: Int) {
|
||||
val urgencyKey = urgency.rawStringValue
|
||||
if (urgencyKey == "safe" || urgencyKey == lastNotifiedUrgency) return
|
||||
lastNotifiedUrgency = urgencyKey
|
||||
|
||||
val (title, body) = when (urgency) {
|
||||
UrgencyLevel.WARNING -> "Bladder Level Rising" to "Estimated $volume ml. Prepare for catheterization."
|
||||
UrgencyLevel.URGENT -> "Catheterization Needed" to "Estimated $volume ml. Please catheterize now."
|
||||
UrgencyLevel.SAFE -> return
|
||||
}
|
||||
|
||||
val priority = if (urgency == UrgencyLevel.URGENT)
|
||||
NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setPriority(priority)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(urgencyKey.hashCode(), notification)
|
||||
} catch (_: SecurityException) { }
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
lastNotifiedUrgency = null
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.example.medilightv2android.models.*
|
||||
import com.google.gson.Gson
|
||||
|
||||
class UserStorage private constructor(context: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: UserStorage? = null
|
||||
fun getInstance(context: Context): UserStorage =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: UserStorage(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
init {
|
||||
migratePinToKeychain()
|
||||
}
|
||||
|
||||
var user: User
|
||||
get() {
|
||||
val json = prefs.getString("user_data", null) ?: return User()
|
||||
return try { gson.fromJson(json, User::class.java) } catch (_: Exception) { User() }
|
||||
}
|
||||
set(value) { prefs.edit().putString("user_data", gson.toJson(value)).apply() }
|
||||
|
||||
var isRegistered: Boolean
|
||||
get() = prefs.getBoolean("is_registered", false)
|
||||
set(value) { prefs.edit().putBoolean("is_registered", value).apply() }
|
||||
|
||||
var pin: String
|
||||
get() {
|
||||
KeychainService.load("user_pin")?.let { return it }
|
||||
return prefs.getString("user_pin", "") ?: ""
|
||||
}
|
||||
set(value) {
|
||||
KeychainService.save("user_pin", value)
|
||||
prefs.edit().remove("user_pin").apply()
|
||||
}
|
||||
|
||||
var hasPin: Boolean
|
||||
get() = prefs.getBoolean("has_pin", false)
|
||||
set(value) { prefs.edit().putBoolean("has_pin", value).apply() }
|
||||
|
||||
var sensorMode: SensorMode
|
||||
get() {
|
||||
val raw = prefs.getString("sensor_mode", null) ?: return SensorMode.PIEZO
|
||||
return try { SensorMode.valueOf(raw) } catch (_: Exception) { SensorMode.PIEZO }
|
||||
}
|
||||
set(value) { prefs.edit().putString("sensor_mode", value.name).apply() }
|
||||
|
||||
var lastDeviceAddress: String?
|
||||
get() = prefs.getString("last_device_address", null)
|
||||
set(value) { prefs.edit().putString("last_device_address", value).apply() }
|
||||
|
||||
var lastDeviceName: String?
|
||||
get() = prefs.getString("last_device_name", null)
|
||||
set(value) { prefs.edit().putString("last_device_name", value).apply() }
|
||||
|
||||
var catheterizationLevel: Int
|
||||
get() {
|
||||
val v = prefs.getInt("catheterization_level", 0)
|
||||
return if (v > 0) v else 8
|
||||
}
|
||||
set(value) { prefs.edit().putInt("catheterization_level", value).apply() }
|
||||
|
||||
var bladderLevel: Int
|
||||
get() = prefs.getInt("bladder_level", 0)
|
||||
set(value) { prefs.edit().putInt("bladder_level", value).apply() }
|
||||
|
||||
var hasCompletedOnboarding: Boolean
|
||||
get() = prefs.getBoolean("has_completed_onboarding", false)
|
||||
set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() }
|
||||
|
||||
var piezoSettings: PiezoSettings
|
||||
get() {
|
||||
val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings()
|
||||
return try { gson.fromJson(json, PiezoSettings::class.java) } catch (_: Exception) { PiezoSettings() }
|
||||
}
|
||||
set(value) { prefs.edit().putString("piezo_settings", gson.toJson(value)).apply() }
|
||||
|
||||
private fun migratePinToKeychain() {
|
||||
val existingPin = prefs.getString("user_pin", null)
|
||||
if (existingPin.isNullOrEmpty()) return
|
||||
val keychainPin = KeychainService.load("user_pin")
|
||||
if (!keychainPin.isNullOrEmpty()) {
|
||||
prefs.edit().remove("user_pin").apply()
|
||||
return
|
||||
}
|
||||
KeychainService.save("user_pin", existingPin)
|
||||
prefs.edit().remove("user_pin").apply()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user