Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fbf97b93e | |||
| c716ab6761 | |||
| 4b42b4431c | |||
| 092552939d |
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "org.soulstone.vigil"
|
applicationId = "org.soulstone.vigil"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 10
|
versionCode = 13
|
||||||
versionName = "0.1.9"
|
versionName = "0.1.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
onRing = { tracker -> ringTracker(tracker) },
|
onRing = { tracker -> ringTracker(tracker) },
|
||||||
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
|
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
|
||||||
onOpenSafety = { showSafety = true },
|
onOpenSafety = { showSafety = true },
|
||||||
onOpenHistory = { showHistory = true }
|
onOpenHistory = { showHistory = true },
|
||||||
|
loadTrail = { id -> repo.trailFor(id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.soulstone.vigil.model.SeparatedState
|
|||||||
import org.soulstone.vigil.model.Sensitivity
|
import org.soulstone.vigil.model.Sensitivity
|
||||||
import org.soulstone.vigil.model.TrackerEcosystem
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
import org.soulstone.vigil.model.TrackerObservation
|
import org.soulstone.vigil.model.TrackerObservation
|
||||||
|
import org.soulstone.vigil.model.TrailPoint
|
||||||
import org.soulstone.vigil.util.Geohash
|
import org.soulstone.vigil.util.Geohash
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -159,6 +160,13 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
|
|
||||||
suspend fun clearAlerts() = db.alertDao().clear()
|
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. */
|
/** Human-readable evidence report; null if there are no alerts yet. */
|
||||||
suspend fun buildTextReport(): String? {
|
suspend fun buildTextReport(): String? {
|
||||||
val alerts = db.alertDao().all()
|
val alerts = db.alertDao().all()
|
||||||
|
|||||||
@@ -37,3 +37,6 @@ enum class TrackerStatus { SAFE_APPROVED, SAFE_BASELINE, OBSERVED, SUSPICIOUS, A
|
|||||||
|
|
||||||
/** Detection sensitivity — trades time-to-alert against false positives. */
|
/** Detection sensitivity — trades time-to-alert against false positives. */
|
||||||
enum class Sensitivity { HIGH, MEDIUM, LOW }
|
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.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
@@ -98,7 +100,9 @@ object TrackerRinger {
|
|||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
var gatt: BluetoothGatt? = null
|
var gatt: BluetoothGatt? = null
|
||||||
var selected: Proto? = 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)
|
if (cont.isActive) cont.resume(msg)
|
||||||
g.disconnect()
|
g.disconnect()
|
||||||
}
|
}
|
||||||
@@ -107,11 +111,14 @@ object TrackerRinger {
|
|||||||
when (newState) {
|
when (newState) {
|
||||||
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
|
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
// An AirTag self-disconnects (status 19) once it has started ringing.
|
// An AirTag self-disconnects (status 19) once it has started ringing.
|
||||||
if (cont.isActive) {
|
if (cont.isActive) {
|
||||||
|
cont.resume(
|
||||||
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
|
if (selected == AIRTAG && status == STATUS_PEER_DISCONNECT)
|
||||||
cont.resume("Ringing… listen for the AirTag.")
|
"Ringing - listen for the AirTag."
|
||||||
else cont.resume("Tracker disconnected before it could ring.")
|
else "Tracker disconnected before it could ring."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
g.close()
|
g.close()
|
||||||
}
|
}
|
||||||
@@ -120,9 +127,9 @@ object TrackerRinger {
|
|||||||
|
|
||||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||||
val proto = PRIORITY.firstOrNull { g.getService(it.service) != null }
|
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)
|
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
|
selected = proto
|
||||||
if (proto.cccd) {
|
if (proto.cccd) {
|
||||||
g.setCharacteristicNotification(ch, true)
|
g.setCharacteristicNotification(ch, true)
|
||||||
@@ -131,32 +138,41 @@ object TrackerRinger {
|
|||||||
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto)
|
if (desc == null || !writeDescriptor(g, desc, v)) writeStart(g, ch, proto)
|
||||||
} else {
|
} 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) {
|
override fun onDescriptorWrite(g: BluetoothGatt, desc: BluetoothGattDescriptor, status: Int) {
|
||||||
val proto = selected ?: return
|
val proto = selected ?: return
|
||||||
val ch = g.getService(proto.service)?.getCharacteristic(proto.characteristic)
|
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)
|
||||||
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 onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) {
|
||||||
// AirTag reports via self-disconnect; others confirm here.
|
if (selected == AIRTAG) { finish("Ringing - listen for the AirTag.", g); return }
|
||||||
if (selected == AIRTAG) {
|
if (status != BluetoothGatt.GATT_SUCCESS) { finish("The tracker refused the ring command.", g); return }
|
||||||
done("Ringing… listen for the AirTag.", g)
|
// DULT/FindMy confirm via an indication. Wait briefly; if none arrives, the tag
|
||||||
} else {
|
// likely isn't separated from its owner (so it won't actually ring).
|
||||||
done(
|
handler.postDelayed({
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS)
|
finish("Ring command sent, but the tracker didn't confirm - it only rings when separated from its owner.", g)
|
||||||
"Ring command sent. A tag only chirps when it's separated from its owner — your own tag that's with you won't."
|
}, 4000)
|
||||||
else "The tracker refused the ring command.", g
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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)
|
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")
|
@Suppress("DEPRECATION")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.lerp
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import org.soulstone.vigil.service.ScanService
|
import org.soulstone.vigil.service.ScanService
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.Radar
|
|||||||
import androidx.compose.material.icons.filled.Shield
|
import androidx.compose.material.icons.filled.Shield
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -50,6 +52,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -60,11 +63,13 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -76,6 +81,7 @@ import org.soulstone.vigil.model.Sensitivity
|
|||||||
import org.soulstone.vigil.model.TrackerEcosystem
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
import org.soulstone.vigil.R
|
import org.soulstone.vigil.R
|
||||||
import org.soulstone.vigil.model.TrackerStatus
|
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.VigilGreen
|
||||||
import org.soulstone.vigil.ui.theme.VigilPeach
|
import org.soulstone.vigil.ui.theme.VigilPeach
|
||||||
import org.soulstone.vigil.ui.theme.VigilRed
|
import org.soulstone.vigil.ui.theme.VigilRed
|
||||||
@@ -94,7 +100,8 @@ fun MainScreen(
|
|||||||
onRing: (TrackerEntity) -> Unit,
|
onRing: (TrackerEntity) -> Unit,
|
||||||
onClearAll: () -> Unit,
|
onClearAll: () -> Unit,
|
||||||
onOpenSafety: () -> Unit,
|
onOpenSafety: () -> Unit,
|
||||||
onOpenHistory: () -> Unit
|
onOpenHistory: () -> Unit,
|
||||||
|
loadTrail: suspend (String) -> List<TrailPoint>
|
||||||
) {
|
) {
|
||||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
@@ -247,7 +254,8 @@ fun MainScreen(
|
|||||||
onDistrust = { id -> onDistrust(id); detail = null },
|
onDistrust = { id -> onDistrust(id); detail = null },
|
||||||
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
|
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
|
||||||
onRing = onRing,
|
onRing = onRing,
|
||||||
onSafety = { onOpenSafety(); detail = null }
|
onSafety = { onOpenSafety(); detail = null },
|
||||||
|
loadTrail = loadTrail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,7 +406,8 @@ private fun TrackerDetail(
|
|||||||
onDistrust: (String) -> Unit,
|
onDistrust: (String) -> Unit,
|
||||||
onFind: (TrackerEntity) -> Unit,
|
onFind: (TrackerEntity) -> Unit,
|
||||||
onRing: (TrackerEntity) -> Unit,
|
onRing: (TrackerEntity) -> Unit,
|
||||||
onSafety: () -> Unit
|
onSafety: () -> Unit,
|
||||||
|
loadTrail: suspend (String) -> List<TrailPoint>
|
||||||
) {
|
) {
|
||||||
val status = statusOf(t)
|
val status = statusOf(t)
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||||
@@ -440,6 +449,25 @@ private fun TrackerDetail(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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))
|
Spacer(Modifier.height(20.dp))
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
|
FilledTonalButton(onClick = { onFind(t) }, modifier = Modifier.weight(1f)) {
|
||||||
@@ -453,6 +481,23 @@ private fun TrackerDetail(
|
|||||||
Text("Ring it")
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
when (status) {
|
when (status) {
|
||||||
TrackerStatus.SAFE_APPROVED ->
|
TrackerStatus.SAFE_APPROVED ->
|
||||||
@@ -554,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 ----------------------------------------------------------
|
// ---- pure helpers ----------------------------------------------------------
|
||||||
|
|
||||||
private const val ACTIVE_WINDOW_MS = 10 * 60_000L // trackers not seen this recently drop off "Active"
|
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))
|
Spacer(Modifier.height(10.dp))
|
||||||
OutlinedButton(
|
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()
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|||||||
+2
-2
@@ -362,8 +362,8 @@
|
|||||||
VIGIL
|
VIGIL
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-tag">
|
<p class="footer-tag">
|
||||||
Temporal counter-tracking for Android. Fully offline, listens only. A
|
Temporal counter-tracking for Android. Fully offline, listens only.
|
||||||
DREAMMAKER project · sibling to OVERWATCH.
|
A sibling to OVERWATCH.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
|
|||||||
Reference in New Issue
Block a user