Survivor side (1/4): safety guidance + first-run onboarding
Build APK / build (push) Waiting to run

- OnboardingScreen: first-launch explainer (what it does, the offline promise, why it needs each permission), then requests permissions.
- SafetyScreen: 'if a tracker is following you' guidance — get safe, think before removing, document, report — with Call 911, the US DV Hotline (call / text START), and SPARC stalking resources. Every action hands off to the dialer/messages/browser; VIGIL stays offline.
- Entry points: a Safety icon in the top bar, and a red 'What should I do?' button on ALERTING/SUSPICIOUS trackers.
Release 0.1.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
Kara Zajac
2026-07-15 19:39:32 -04:00
parent 1c0a6f216d
commit 24a2dc83e5
6 changed files with 303 additions and 34 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "org.soulstone.vigil"
minSdk = 26
targetSdk = 35
versionCode = 8
versionName = "0.1.7"
versionCode = 9
versionName = "0.1.8"
}
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
@@ -14,6 +14,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
@@ -24,6 +26,8 @@ import org.soulstone.vigil.ring.TrackerRinger
import org.soulstone.vigil.data.settings.Settings
import org.soulstone.vigil.service.ScanService
import org.soulstone.vigil.ui.MainScreen
import org.soulstone.vigil.ui.OnboardingScreen
import org.soulstone.vigil.ui.SafetyScreen
import org.soulstone.vigil.ui.theme.VigilTheme
class MainActivity : ComponentActivity() {
@@ -68,6 +72,15 @@ class MainActivity : ComponentActivity() {
setContent {
VigilTheme {
val onboarded by settings.onboarded.collectAsState()
var showSafety by remember { mutableStateOf(false) }
when {
!onboarded -> OnboardingScreen(onFinish = {
settings.setOnboarded(true)
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
})
showSafety -> SafetyScreen(onBack = { showSafety = false })
else -> {
val running by ScanService.running.collectAsState()
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
val sensitivity by settings.sensitivity.collectAsState()
@@ -96,11 +109,14 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { repo.clearBaseline(id) }
},
onRing = { tracker -> ringTracker(tracker) },
onClearAll = { lifecycleScope.launch { repo.clearAll() } }
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
onOpenSafety = { showSafety = true }
)
}
}
}
}
}
override fun onResume() {
super.onResume()
@@ -25,8 +25,16 @@ class Settings private constructor(context: Context) {
_sensitivity.value = value
}
private val _onboarded = MutableStateFlow(prefs.getBoolean(KEY_ONBOARDED, false))
val onboarded: StateFlow<Boolean> = _onboarded.asStateFlow()
fun setOnboarded(value: Boolean) {
prefs.edit().putBoolean(KEY_ONBOARDED, value).apply()
_onboarded.value = value
}
companion object {
private const val KEY_SENSITIVITY = "sensitivity"
private const val KEY_ONBOARDED = "onboarded"
@Volatile private var INSTANCE: Settings? = null
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Bluetooth
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.Lock
import androidx.compose.material.icons.filled.Radar
import androidx.compose.material.icons.filled.Shield
@@ -90,7 +91,8 @@ fun MainScreen(
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit,
onRing: (TrackerEntity) -> Unit,
onClearAll: () -> Unit
onClearAll: () -> Unit,
onOpenSafety: () -> Unit
) {
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
@@ -119,6 +121,13 @@ fun MainScreen(
}
},
actions = {
IconButton(onClick = onOpenSafety) {
Icon(
Icons.Filled.HealthAndSafety,
contentDescription = "Safety & help",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (trackers.isNotEmpty()) {
IconButton(onClick = onClearAll) {
Icon(
@@ -228,7 +237,8 @@ fun MainScreen(
onApprove = { id, a -> onApprove(id, a); detail = null },
onDistrust = { id -> onDistrust(id); detail = null },
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
onRing = onRing
onRing = onRing,
onSafety = { onOpenSafety(); detail = null }
)
}
}
@@ -378,7 +388,8 @@ private fun TrackerDetail(
onApprove: (String, Boolean) -> Unit,
onDistrust: (String) -> Unit,
onFind: (TrackerEntity) -> Unit,
onRing: (TrackerEntity) -> Unit
onRing: (TrackerEntity) -> Unit,
onSafety: () -> Unit
) {
val status = statusOf(t)
Column(Modifier.fillMaxWidth().padding(24.dp)) {
@@ -389,6 +400,18 @@ private fun TrackerDetail(
}
Spacer(Modifier.height(4.dp))
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
if (status == TrackerStatus.ALERTING || status == TrackerStatus.SUSPICIOUS) {
Spacer(Modifier.height(14.dp))
Button(
onClick = onSafety,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
) {
Icon(Icons.Filled.HealthAndSafety, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text("What should I do?", fontWeight = FontWeight.Bold)
}
}
Spacer(Modifier.height(16.dp))
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
@@ -0,0 +1,87 @@
package org.soulstone.vigil.ui
import androidx.compose.foundation.Image
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.soulstone.vigil.R
/** First-run: what VIGIL does, its offline promise, and why it needs its permissions. */
@Composable
fun OnboardingScreen(onFinish: () -> Unit) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(32.dp))
Image(painterResource(R.drawable.vigil_logo), null, modifier = Modifier.size(76.dp))
Spacer(Modifier.height(14.dp))
Text("VIGIL", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
"Know what's been following you.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(36.dp))
Point(
Icons.Filled.Sensors,
"Finds what follows you",
"Detects AirTags, Tile, Samsung SmartTags and Find My trackers that travel with you over time — not just whatever happens to be nearby."
)
Point(
Icons.Filled.Lock,
"Stays on your phone",
"No account, no internet, no telemetry. Everything VIGIL learns lives on this device and never leaves it."
)
Point(
Icons.Filled.LocationOn,
"Why it needs permissions",
"Bluetooth to hear trackers, location to tell one is moving with you across places, and notifications to warn you. That's the whole reason it asks."
)
Spacer(Modifier.height(32.dp))
Button(onClick = onFinish, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Text("Get started", fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun Point(icon: ImageVector, title: String, body: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(26.dp).padding(top = 2.dp))
Spacer(Modifier.size(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -0,0 +1,135 @@
package org.soulstone.vigil.ui
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.soulstone.vigil.ui.theme.VigilRed
/** Survivor-centred guidance shown when a tracker may be following you. Every
* action hands off to the dialer / messages / browser — VIGIL stays offline. */
@Composable
fun SafetyScreen(onBack: () -> Unit) {
BackHandler(onBack = onBack)
val ctx = LocalContext.current
fun go(intent: Intent) = runCatching { ctx.startActivity(intent) }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
Spacer(Modifier.size(4.dp))
Text("If a tracker is following you", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(8.dp))
Text(
"VIGIL is a detection tool, not a substitute for professional help. If you're in immediate danger, call emergency services.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(20.dp))
Step(1, "Get somewhere safe", "If you feel in danger, head to a public place or someone you trust before doing anything else.")
Step(2, "Think before you remove it", "Turning a tracker off can alert whoever placed it and escalate the risk. If you might be in danger, consider documenting it and getting help first.")
Step(3, "Document what you can", "Save when and where it was seen (use Export), and photograph the device if you find it. A dated record helps police and advocates.")
Step(4, "Report it", "Contact local police — 911 if you're in immediate danger. If this involves a partner or ex, a domestic-violence advocate can help you make a safety plan.")
Spacer(Modifier.height(24.dp))
Text("Get help", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
Spacer(Modifier.height(10.dp))
Button(
onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:911"))) },
modifier = Modifier.fillMaxWidth().height(50.dp),
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
) {
Icon(Icons.Filled.Call, null); Spacer(Modifier.size(8.dp)); Text("Call 911 (emergency)", fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(10.dp))
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("U.S. National Domestic Violence Hotline", fontWeight = FontWeight.SemiBold)
Text("Free, confidential, 24/7 — help with stalking and abuse, including safety planning.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:18007997233"))) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Call, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Call")
}
OutlinedButton(onClick = { go(Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:88788")).putExtra("sms_body", "START")) }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Chat, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Text START")
}
}
}
}
Spacer(Modifier.height(10.dp))
OutlinedButton(
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/help-for-victims/"))) },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(8.dp)); Text("Stalking help & resources (SPARC)")
}
Spacer(Modifier.height(16.dp))
Text(
"These open your phone, messages, or browser — VIGIL itself never connects to the internet. Resources shown are U.S.; check what's available where you are.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun Step(n: Int, title: String, body: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 9.dp)) {
Box(
Modifier.size(26.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) { Text("$n", color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) }
Spacer(Modifier.size(14.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}