diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d766d77..7c600b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt index f2db6e0..3dab5a4 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/MainActivity.kt @@ -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,36 +72,48 @@ class MainActivity : ComponentActivity() { setContent { VigilTheme { - val running by ScanService.running.collectAsState() - val trackers by repo.observeTrackers().collectAsState(initial = emptyList()) - val sensitivity by settings.sensitivity.collectAsState() - val granted by permissionsGranted + 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() + val granted by permissionsGranted - MainScreen( - running = running, - trackers = trackers, - sensitivity = sensitivity, - permissionMessage = if (granted) null - else "Grant Bluetooth + location to start watching", - onStartStop = { - if (running) { - ScanService.stop(this) - } else if (granted) { - ScanService.start(this) - } else { - permissionLauncher.launch(requiredPermissions) - } - }, - onSetSensitivity = { settings.setSensitivity(it) }, - onApprove = { id, approved -> - lifecycleScope.launch { repo.setApproved(id, approved) } - }, - onDistrust = { id -> - lifecycleScope.launch { repo.clearBaseline(id) } - }, - onRing = { tracker -> ringTracker(tracker) }, - onClearAll = { lifecycleScope.launch { repo.clearAll() } } - ) + MainScreen( + running = running, + trackers = trackers, + sensitivity = sensitivity, + permissionMessage = if (granted) null + else "Grant Bluetooth + location to start watching", + onStartStop = { + if (running) { + ScanService.stop(this) + } else if (granted) { + ScanService.start(this) + } else { + permissionLauncher.launch(requiredPermissions) + } + }, + onSetSensitivity = { settings.setSensitivity(it) }, + onApprove = { id, approved -> + lifecycleScope.launch { repo.setApproved(id, approved) } + }, + onDistrust = { id -> + lifecycleScope.launch { repo.clearBaseline(id) } + }, + onRing = { tracker -> ringTracker(tracker) }, + onClearAll = { lifecycleScope.launch { repo.clearAll() } }, + onOpenSafety = { showSafety = true } + ) + } + } } } } diff --git a/app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt b/app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt index 577ea50..027bf9c 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/data/settings/Settings.kt @@ -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 = _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) { diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt index 3ecfe2f..e1def4e 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -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(null) } var finding by remember { mutableStateOf(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") diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/OnboardingScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/OnboardingScreen.kt new file mode 100644 index 0000000..432d389 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/OnboardingScreen.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/SafetyScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/SafetyScreen.kt new file mode 100644 index 0000000..611aa33 --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/SafetyScreen.kt @@ -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) + } + } +}