Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fbf97b93e | |||
| c716ab6761 | |||
| 4b42b4431c | |||
| 092552939d | |||
| b8da7e87ff |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "org.soulstone.vigil"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 9
|
||||
versionName = "0.1.8"
|
||||
versionCode = 13
|
||||
versionName = "0.1.12"
|
||||
}
|
||||
|
||||
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
||||
|
||||
@@ -67,5 +67,16 @@
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|location" />
|
||||
|
||||
<!-- Shares exported evidence files (report + GPX) with other apps. No network. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -17,14 +17,17 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import org.soulstone.vigil.data.TrackerRepository
|
||||
import org.soulstone.vigil.data.db.TrackerEntity
|
||||
import org.soulstone.vigil.data.db.VigilDatabase
|
||||
import org.soulstone.vigil.ring.TrackerRinger
|
||||
import org.soulstone.vigil.data.settings.Settings
|
||||
import org.soulstone.vigil.service.ScanService
|
||||
import org.soulstone.vigil.ui.HistoryScreen
|
||||
import org.soulstone.vigil.ui.MainScreen
|
||||
import org.soulstone.vigil.ui.OnboardingScreen
|
||||
import org.soulstone.vigil.ui.SafetyScreen
|
||||
@@ -74,12 +77,22 @@ class MainActivity : ComponentActivity() {
|
||||
VigilTheme {
|
||||
val onboarded by settings.onboarded.collectAsState()
|
||||
var showSafety by remember { mutableStateOf(false) }
|
||||
var showHistory by remember { mutableStateOf(false) }
|
||||
when {
|
||||
!onboarded -> OnboardingScreen(onFinish = {
|
||||
settings.setOnboarded(true)
|
||||
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
|
||||
})
|
||||
showSafety -> SafetyScreen(onBack = { showSafety = false })
|
||||
showHistory -> {
|
||||
val alerts by repo.observeAlerts().collectAsState(initial = emptyList())
|
||||
HistoryScreen(
|
||||
alerts = alerts,
|
||||
onExport = { exportEvidence() },
|
||||
onClear = { lifecycleScope.launch { repo.clearAlerts() } },
|
||||
onBack = { showHistory = false }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val running by ScanService.running.collectAsState()
|
||||
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
|
||||
@@ -110,7 +123,9 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onRing = { tracker -> ringTracker(tracker) },
|
||||
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
|
||||
onOpenSafety = { showSafety = true }
|
||||
onOpenSafety = { showSafety = true },
|
||||
onOpenHistory = { showHistory = true },
|
||||
loadTrail = { id -> repo.trailFor(id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +150,34 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportEvidence() {
|
||||
lifecycleScope.launch {
|
||||
val report = repo.buildTextReport()
|
||||
if (report == null) {
|
||||
Toast.makeText(this@MainActivity, "No alerts to export yet.", Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
runCatching {
|
||||
val dir = File(cacheDir, "exports").apply { mkdirs() }
|
||||
val txt = File(dir, "vigil-report.txt").apply { writeText(report) }
|
||||
val gpx = File(dir, "vigil-track.gpx").apply { writeText(repo.buildGpx()) }
|
||||
val auth = "$packageName.fileprovider"
|
||||
val uris = arrayListOf(
|
||||
FileProvider.getUriForFile(this@MainActivity, auth, txt),
|
||||
FileProvider.getUriForFile(this@MainActivity, auth, gpx)
|
||||
)
|
||||
val share = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
type = "*/*"
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(share, "Export VIGIL evidence"))
|
||||
}.onFailure {
|
||||
Toast.makeText(this@MainActivity, "Export failed: ${it.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun openAppSettings() {
|
||||
startActivity(
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.soulstone.vigil.data
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.soulstone.vigil.data.db.AlertEntity
|
||||
import org.soulstone.vigil.data.db.SightingEntity
|
||||
import org.soulstone.vigil.data.db.TrackerEntity
|
||||
import org.soulstone.vigil.data.db.VigilDatabase
|
||||
@@ -11,8 +12,14 @@ import org.soulstone.vigil.detect.CoMovementEvaluator
|
||||
import org.soulstone.vigil.model.RiskState
|
||||
import org.soulstone.vigil.model.SeparatedState
|
||||
import org.soulstone.vigil.model.Sensitivity
|
||||
import org.soulstone.vigil.model.TrackerEcosystem
|
||||
import org.soulstone.vigil.model.TrackerObservation
|
||||
import org.soulstone.vigil.model.TrailPoint
|
||||
import org.soulstone.vigil.util.Geohash
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* The temporal core. Persists sightings, maintains the learned baseline, and runs
|
||||
@@ -133,10 +140,98 @@ class TrackerRepository(private val db: VigilDatabase) {
|
||||
lastMac = obs.mac
|
||||
)
|
||||
db.trackerDao().upsert(updated)
|
||||
if (newlyAlerting) {
|
||||
db.alertDao().insert(
|
||||
AlertEntity(
|
||||
trackerId = obs.stableId,
|
||||
ecosystem = obs.ecosystem.name,
|
||||
label = obs.label,
|
||||
timestamp = now,
|
||||
distinctPlaces = assessment.distinctPlaces,
|
||||
peakRssi = updated.peakRssi,
|
||||
lat = lat, lon = lon
|
||||
)
|
||||
)
|
||||
}
|
||||
RecordResult(updated, newlyAlerting)
|
||||
}
|
||||
|
||||
fun observeAlerts(): Flow<List<AlertEntity>> = db.alertDao().observeAll()
|
||||
|
||||
suspend fun clearAlerts() = db.alertDao().clear()
|
||||
|
||||
/** Geotagged sightings of one tracker, in time order, for the in-app trail. */
|
||||
suspend fun trailFor(id: String): List<TrailPoint> =
|
||||
db.sightingDao().allFor(id).mapNotNull { s ->
|
||||
val la = s.lat; val lo = s.lon
|
||||
if (la != null && lo != null) TrailPoint(la, lo) else null
|
||||
}
|
||||
|
||||
/** Human-readable evidence report; null if there are no alerts yet. */
|
||||
suspend fun buildTextReport(): String? {
|
||||
val alerts = db.alertDao().all()
|
||||
if (alerts.isEmpty()) return null
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("VIGIL — tracker evidence report")
|
||||
sb.appendLine("Generated: ${fmt.format(Date())}")
|
||||
sb.appendLine("Times are this phone's local time; locations are approximate (phone GPS).")
|
||||
sb.appendLine("=".repeat(52))
|
||||
for ((trackerId, group) in alerts.groupBy { it.trackerId }) {
|
||||
val head = group.first()
|
||||
sb.appendLine()
|
||||
sb.appendLine("${ecoLabel(head.ecosystem)} — ${head.label}")
|
||||
sb.appendLine("Identity: $trackerId")
|
||||
sb.appendLine("Flagged ${group.size} time(s):")
|
||||
for (a in group) {
|
||||
val where = if (a.lat != null && a.lon != null) "%.5f, %.5f".format(a.lat, a.lon) else "no location"
|
||||
sb.appendLine(" - ${fmt.format(Date(a.timestamp))} : ${a.distinctPlaces} places, peak ${a.peakRssi} dBm, at $where")
|
||||
}
|
||||
val geo = db.sightingDao().allFor(trackerId).filter { it.lat != null && it.lon != null }
|
||||
if (geo.isNotEmpty()) {
|
||||
sb.appendLine(" Seen with you at ${geo.size} location(s):")
|
||||
for (s in geo) {
|
||||
sb.appendLine(" ${fmt.format(Date(s.timestamp))} ${"%.5f, %.5f".format(s.lat, s.lon)} ${s.rssi} dBm")
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.appendLine()
|
||||
sb.appendLine("=".repeat(52))
|
||||
sb.appendLine("Recorded by VIGIL, an offline anti-tracking app — a log of Bluetooth trackers detected moving with this phone.")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** GPX of every geotagged sighting, to open in a maps app. */
|
||||
suspend fun buildGpx(): String {
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
|
||||
sb.appendLine("""<gpx version="1.1" creator="VIGIL" xmlns="http://www.topografix.com/GPX/1/1">""")
|
||||
for (s in db.sightingDao().allGeotagged()) {
|
||||
sb.appendLine(""" <wpt lat="${s.lat}" lon="${s.lon}">""")
|
||||
sb.appendLine(" <time>${fmt.format(Date(s.timestamp))}</time>")
|
||||
sb.appendLine(" <name>${trackerShortName(s.trackerId)} ${s.rssi}dBm</name>")
|
||||
sb.appendLine(" </wpt>")
|
||||
}
|
||||
sb.appendLine("</gpx>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RETENTION_DAYS = 14
|
||||
}
|
||||
}
|
||||
|
||||
private fun ecoLabel(name: String): String =
|
||||
runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||
|
||||
private fun trackerShortName(stableId: String): String = when (stableId.substringBefore(':')) {
|
||||
"apple" -> "AppleFindMy"
|
||||
"fmdn" -> "GoogleFMD"
|
||||
"samsung" -> "SmartTag"
|
||||
"tile" -> "Tile"
|
||||
"dult" -> "DULT"
|
||||
else -> "Tracker"
|
||||
}
|
||||
|
||||
@@ -68,6 +68,21 @@ data class PlaceEntity(
|
||||
val anchor: Boolean = false
|
||||
)
|
||||
|
||||
/** A recorded alert — when a tracker first crossed into ALERTING. Kept for the
|
||||
* history list and the evidence export. */
|
||||
@Entity(tableName = "alerts")
|
||||
data class AlertEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val trackerId: String,
|
||||
val ecosystem: String,
|
||||
val label: String,
|
||||
val timestamp: Long,
|
||||
val distinctPlaces: Int,
|
||||
val peakRssi: Int,
|
||||
val lat: Double? = null,
|
||||
val lon: Double? = null
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface TrackerDao {
|
||||
@Query("SELECT * FROM trackers WHERE stableId = :id")
|
||||
@@ -104,6 +119,12 @@ interface SightingDao {
|
||||
@Query("SELECT * FROM sightings WHERE trackerId = :id AND timestamp >= :since ORDER BY timestamp")
|
||||
suspend fun recentFor(id: String, since: Long): List<SightingEntity>
|
||||
|
||||
@Query("SELECT * FROM sightings WHERE trackerId = :id ORDER BY timestamp")
|
||||
suspend fun allFor(id: String): List<SightingEntity>
|
||||
|
||||
@Query("SELECT * FROM sightings WHERE lat IS NOT NULL ORDER BY timestamp")
|
||||
suspend fun allGeotagged(): List<SightingEntity>
|
||||
|
||||
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
|
||||
suspend fun prune(cutoff: Long)
|
||||
|
||||
@@ -120,15 +141,31 @@ interface PlaceDao {
|
||||
suspend fun upsert(place: PlaceEntity)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface AlertDao {
|
||||
@Insert
|
||||
suspend fun insert(alert: AlertEntity)
|
||||
|
||||
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||
fun observeAll(): Flow<List<AlertEntity>>
|
||||
|
||||
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||
suspend fun all(): List<AlertEntity>
|
||||
|
||||
@Query("DELETE FROM alerts")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
||||
version = 3,
|
||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class, AlertEntity::class],
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class VigilDatabase : RoomDatabase() {
|
||||
abstract fun trackerDao(): TrackerDao
|
||||
abstract fun sightingDao(): SightingDao
|
||||
abstract fun placeDao(): PlaceDao
|
||||
abstract fun alertDao(): AlertDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: VigilDatabase? = null
|
||||
|
||||
@@ -37,3 +37,6 @@ enum class TrackerStatus { SAFE_APPROVED, SAFE_BASELINE, OBSERVED, SUSPICIOUS, A
|
||||
|
||||
/** Detection sensitivity — trades time-to-alert against false positives. */
|
||||
enum class Sensitivity { HIGH, MEDIUM, LOW }
|
||||
|
||||
/** A geotagged point where a tracker was seen, for the in-app co-movement trail. */
|
||||
data class TrailPoint(val lat: Double, val lon: Double)
|
||||
|
||||
@@ -12,6 +12,8 @@ import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
@@ -98,7 +100,9 @@ object TrackerRinger {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
var gatt: BluetoothGatt? = null
|
||||
var selected: Proto? = null
|
||||
fun done(msg: String, g: BluetoothGatt) {
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
fun finish(msg: String, g: BluetoothGatt) {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
if (cont.isActive) cont.resume(msg)
|
||||
g.disconnect()
|
||||
}
|
||||
@@ -107,11 +111,14 @@ object TrackerRinger {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
// An AirTag self-disconnects (status 19) once it has started ringing.
|
||||
if (cont.isActive) {
|
||||
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
|
||||
cont.resume("Ringing… listen for the AirTag.")
|
||||
else cont.resume("Tracker disconnected before it could ring.")
|
||||
cont.resume(
|
||||
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
|
||||
"Ringing - listen for the AirTag."
|
||||
else "Tracker disconnected before it could ring."
|
||||
)
|
||||
}
|
||||
g.close()
|
||||
}
|
||||
@@ -120,9 +127,9 @@ object TrackerRinger {
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
val proto = PRIORITY.firstOrNull { g.getService(it.service) != null }
|
||||
?: return done("This tracker type doesn't expose a remote-ring service.", g)
|
||||
?: return finish("This tracker type doesn't expose a remote-ring service.", g)
|
||||
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
||||
?: return done("Ring characteristic missing on this tracker.", g)
|
||||
?: return finish("Ring characteristic missing on this tracker.", g)
|
||||
selected = proto
|
||||
if (proto.cccd) {
|
||||
g.setCharacteristicNotification(ch, true)
|
||||
@@ -131,32 +138,41 @@ object TrackerRinger {
|
||||
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto)
|
||||
} else {
|
||||
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
|
||||
if (!writeStart(g, ch, proto)) finish("Couldn't send the ring command.", g)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) {
|
||||
val proto = selected ?: return
|
||||
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
||||
?: return done("Ring characteristic missing on this tracker.", g)
|
||||
if (!writeStart(g, ch, proto)) done("Couldn't send the ring command.", g)
|
||||
?: return finish("Ring characteristic missing on this tracker.", g)
|
||||
if (!writeStart(g, ch, proto)) finish("Couldn't send the ring command.", g)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||
// AirTag reports via self-disconnect; others confirm here.
|
||||
if (selected == AIRTAG) {
|
||||
done("Ringing… listen for the AirTag.", g)
|
||||
} else {
|
||||
done(
|
||||
if (status == BluetoothGatt.GATT_SUCCESS)
|
||||
"Ring command sent. A tag only chirps when it's separated from its owner — your own tag that's with you won't."
|
||||
else "The tracker refused the ring command.", g
|
||||
)
|
||||
}
|
||||
if (selected == AIRTAG) { finish("Ringing - listen for the AirTag.", g); return }
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) { finish("The tracker refused the ring command.", g); return }
|
||||
// DULT/FindMy confirm via an indication. Wait briefly; if none arrives, the tag
|
||||
// likely isn't separated from its owner (so it won't actually ring).
|
||||
handler.postDelayed({
|
||||
finish("Ring command sent, but the tracker didn't confirm - it only rings when separated from its owner.", g)
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) {
|
||||
finish("Ringing confirmed - listen for the tracker.", g)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
finish("Ringing confirmed - listen for the tracker.", g)
|
||||
}
|
||||
}
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
cont.invokeOnCancellation { runCatching { gatt?.disconnect(); gatt?.close() } }
|
||||
cont.invokeOnCancellation {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
runCatching { gatt?.disconnect(); gatt?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.soulstone.vigil.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.soulstone.vigil.data.db.AlertEntity
|
||||
import org.soulstone.vigil.model.TrackerEcosystem
|
||||
|
||||
/** Log of past alerts, with a one-tap evidence export (report + GPX). */
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
alerts: List<AlertEntity>,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text("Alert history", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onExport, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.Share, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text("Export evidence")
|
||||
}
|
||||
OutlinedButton(onClick = onClear, enabled = alerts.isNotEmpty()) { Text("Clear") }
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Export saves a timestamped report and a GPX track (open it in a maps app). Files stay on your phone until you share them.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (alerts.isEmpty()) {
|
||||
Text(
|
||||
"No alerts yet. When a tracker is confirmed following you, it'll be logged here.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(alerts, key = { it.id }) { a -> AlertRow(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertRow(a: AlertEntity) {
|
||||
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text(ecoName(a.ecosystem), fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"${a.distinctPlaces} places · peak ${a.peakRssi} dBm · ${rel(a.timestamp)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ecoName(name: String) = runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||
private fun rel(ts: Long) = DateUtils.getRelativeTimeSpanString(ts, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import org.soulstone.vigil.service.ScanService
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -33,11 +34,13 @@ import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.GppMaybe
|
||||
import androidx.compose.material.icons.filled.HealthAndSafety
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Radar
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -49,6 +52,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -59,11 +63,13 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -75,6 +81,7 @@ import org.soulstone.vigil.model.Sensitivity
|
||||
import org.soulstone.vigil.model.TrackerEcosystem
|
||||
import org.soulstone.vigil.R
|
||||
import org.soulstone.vigil.model.TrackerStatus
|
||||
import org.soulstone.vigil.model.TrailPoint
|
||||
import org.soulstone.vigil.ui.theme.VigilGreen
|
||||
import org.soulstone.vigil.ui.theme.VigilPeach
|
||||
import org.soulstone.vigil.ui.theme.VigilRed
|
||||
@@ -92,7 +99,9 @@ fun MainScreen(
|
||||
onDistrust: (String) -> Unit,
|
||||
onRing: (TrackerEntity) -> Unit,
|
||||
onClearAll: () -> Unit,
|
||||
onOpenSafety: () -> Unit
|
||||
onOpenSafety: () -> Unit,
|
||||
onOpenHistory: () -> Unit,
|
||||
loadTrail: suspend (String) -> List<TrailPoint>
|
||||
) {
|
||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||
@@ -121,6 +130,13 @@ fun MainScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenHistory) {
|
||||
Icon(
|
||||
Icons.Filled.History,
|
||||
contentDescription = "Alert history",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSafety) {
|
||||
Icon(
|
||||
Icons.Filled.HealthAndSafety,
|
||||
@@ -238,7 +254,8 @@ fun MainScreen(
|
||||
onDistrust = { id -> onDistrust(id); detail = null },
|
||||
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
|
||||
onRing = onRing,
|
||||
onSafety = { onOpenSafety(); detail = null }
|
||||
onSafety = { onOpenSafety(); detail = null },
|
||||
loadTrail = loadTrail
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -389,7 +406,8 @@ private fun TrackerDetail(
|
||||
onDistrust: (String) -> Unit,
|
||||
onFind: (TrackerEntity) -> Unit,
|
||||
onRing: (TrackerEntity) -> Unit,
|
||||
onSafety: () -> Unit
|
||||
onSafety: () -> Unit,
|
||||
loadTrail: suspend (String) -> List<TrailPoint>
|
||||
) {
|
||||
val status = statusOf(t)
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||
@@ -431,6 +449,25 @@ private fun TrackerDetail(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
val trail by produceState(initialValue = emptyList<TrailPoint>(), t.stableId) { value = loadTrail(t.stableId) }
|
||||
if (trail.size >= 2) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text("Where it's moved with you", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TrailView(
|
||||
trail,
|
||||
Modifier.fillMaxWidth().height(150.dp).clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
)
|
||||
Text(
|
||||
"A schematic of the ${trail.size} points it was seen — not a real map. Use Export for that.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
|
||||
@@ -444,6 +481,23 @@ private fun TrackerDetail(
|
||||
Text("Ring it")
|
||||
}
|
||||
}
|
||||
var showInfo by remember { mutableStateOf(false) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = { showInfo = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Who owns it?")
|
||||
}
|
||||
if (showInfo) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showInfo = false },
|
||||
confirmButton = { TextButton(onClick = { showInfo = false }) { Text("Got it") } },
|
||||
title = { Text("Who owns this tracker?") },
|
||||
text = {
|
||||
Text(
|
||||
"Tap the tracker against the top of your phone (NFC). If it's an AirTag or a DULT tag in lost mode, your phone opens a page with the owner's masked phone or email. VIGIL stays offline - the tag's NFC hands the link to your browser."
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (status) {
|
||||
TrackerStatus.SAFE_APPROVED ->
|
||||
@@ -545,6 +599,38 @@ private fun FinderScreen(t: TrackerEntity, onRing: (TrackerEntity) -> Unit, onCl
|
||||
}
|
||||
}
|
||||
|
||||
/** Offline schematic of a tracker's geotagged sightings — points + path, scaled
|
||||
* to fit. Not a real map (VIGIL has no network); the GPX export opens in one. */
|
||||
@Composable
|
||||
private fun TrailView(points: List<TrailPoint>, modifier: Modifier = Modifier) {
|
||||
val line = Color(0xFF89B4FA)
|
||||
val startC = Color(0xFFA6E3A1)
|
||||
val endC = Color(0xFFFAB387)
|
||||
Canvas(modifier) {
|
||||
if (points.size < 2) return@Canvas
|
||||
val pad = 18f
|
||||
val minLat = points.minOf { it.lat }; val maxLat = points.maxOf { it.lat }
|
||||
val minLon = points.minOf { it.lon }; val maxLon = points.maxOf { it.lon }
|
||||
val rLat = (maxLat - minLat).let { if (it > 1e-9) it else 1e-9 }
|
||||
val rLon = (maxLon - minLon).let { if (it > 1e-9) it else 1e-9 }
|
||||
val w = size.width - 2 * pad
|
||||
val h = size.height - 2 * pad
|
||||
val pts = points.map { p ->
|
||||
Offset(
|
||||
pad + ((p.lon - minLon) / rLon).toFloat() * w,
|
||||
pad + (1f - ((p.lat - minLat) / rLat).toFloat()) * h
|
||||
)
|
||||
}
|
||||
for (i in 0 until pts.size - 1) {
|
||||
drawLine(line.copy(alpha = 0.55f), pts[i], pts[i + 1], strokeWidth = 3f)
|
||||
}
|
||||
pts.forEachIndexed { i, o ->
|
||||
val c = if (i == 0) startC else if (i == pts.lastIndex) endC else line
|
||||
drawCircle(c, radius = if (i == 0 || i == pts.lastIndex) 6f else 4f, center = o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pure helpers ----------------------------------------------------------
|
||||
|
||||
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
|
||||
|
||||
@@ -102,10 +102,22 @@ fun SafetyScreen(onBack: () -> Unit) {
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedButton(
|
||||
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/help-for-victims/"))) },
|
||||
onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:18554842846"))) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(8.dp)); Text("Stalking help & resources (SPARC)")
|
||||
Icon(Icons.Filled.Call, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("VictimConnect — 1-855-484-2846 (stalking victims)")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedButton(
|
||||
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/what-to-do-if-you-are-being-stalked/"))) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("What to do if you're being stalked (SPARC)")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="exports" path="exports/" />
|
||||
</paths>
|
||||
+2
-2
@@ -362,8 +362,8 @@
|
||||
VIGIL
|
||||
</div>
|
||||
<p class="footer-tag">
|
||||
Temporal counter-tracking for Android. Fully offline, listens only. A
|
||||
DREAMMAKER project · sibling to OVERWATCH.
|
||||
Temporal counter-tracking for Android. Fully offline, listens only.
|
||||
A sibling to OVERWATCH.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
|
||||
Reference in New Issue
Block a user