package com.shire.mobile import android.Manifest import android.app.Activity import android.app.job.JobInfo import android.app.job.JobParameters import android.app.job.JobScheduler import android.app.job.JobService import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection import java.net.URI import java.net.URL import java.nio.charset.StandardCharsets import java.util.Locale private val ApprovalBackground = Color(0xFF090B10) private val ApprovalSurface = Color(0xFF141923) private val ApprovalRaised = Color(0xFF1B2330) private val ApprovalPurple = Color(0xFFB174FF) private val ApprovalBlue = Color(0xFF53B7FF) private val ApprovalGreen = Color(0xFF20E887) private val ApprovalAmber = Color(0xFFF2B84B) private val ApprovalRed = Color(0xFFFF5A67) private val ApprovalText = Color(0xFFE8FFF2) private val ApprovalMuted = Color(0xFF91A0AA) internal data class LiveApprovalFile( val id: String, val name: String, val sizeHuman: String, val sha256: String, val mimeType: String, val isEvidenceReport: Boolean ) internal data class LiveApproval( val id: String, val title: String, val stage: String, val purpose: String, val affects: String, val risk: String, val urgency: String, val estimate: String, val rollback: String, val status: String, val createdAt: String, val evidenceRoot: String, val reportSha256: String, val decisionNote: String?, val permissionsRequested: List, val permissionsNotGranted: List, val files: List ) class ApprovalInboxActivity : ComponentActivity() { private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ApprovalNotifications.ensureChannel(this) ApprovalPollJobService.schedule(this) ApprovalNotifications.checkNow(this) if ( Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } val requestedApprovalId = intent ?.data ?.lastPathSegment ?.takeIf { it.startsWith("APR-") } setContent { MaterialTheme( colorScheme = darkColorScheme( primary = ApprovalPurple, secondary = ApprovalBlue, background = ApprovalBackground, surface = ApprovalSurface, onPrimary = Color.Black, onBackground = ApprovalText, onSurface = ApprovalText ) ) { Surface( modifier = Modifier.fillMaxSize(), color = ApprovalBackground ) { ApprovalInboxScreen( requestedApprovalId = requestedApprovalId, onClose = { finish() } ) } } } } } internal object ApprovalApi { private val baseUrl = BuildConfig.APPROVAL_API_BASE_URL.trimEnd('/') private val token = BuildConfig.APPROVAL_API_TOKEN private fun validatePrivateEndpoint() { val uri = URI(baseUrl) require(uri.scheme == "http") { "Approval Hub must use the private Tailscale HTTP endpoint" } val host = uri.host ?: error("Approval Hub host is missing") require(isTailnetAddress(host)) { "Approval Hub refused a non-Tailscale endpoint" } require(token.length >= 32) { "Approval Hub token is missing" } } private fun isTailnetAddress(host: String): Boolean { val parts = host.split(".") if (parts.size == 4) { val values = parts.mapNotNull { it.toIntOrNull() } if (values.size == 4) { return values[0] == 100 && values[1] in 64..127 } } return host.lowercase(Locale.ROOT).startsWith("fd7a:115c:a1e0:") } private fun request( path: String, method: String = "GET", body: JSONObject? = null ): JSONObject { validatePrivateEndpoint() val connection = URL(baseUrl + path).openConnection() as HttpURLConnection try { connection.requestMethod = method connection.connectTimeout = 8_000 connection.readTimeout = 30_000 connection.setRequestProperty("Accept", "application/json") connection.setRequestProperty("Authorization", "Bearer $token") connection.setRequestProperty("Cache-Control", "no-store") if (body != null) { val bytes = body.toString().toByteArray(StandardCharsets.UTF_8) connection.doOutput = true connection.setRequestProperty("Content-Type", "application/json") connection.setFixedLengthStreamingMode(bytes.size) connection.outputStream.use { it.write(bytes) } } val code = connection.responseCode val stream = if (code in 200..299) { connection.inputStream } else { connection.errorStream } val text = stream ?.bufferedReader(StandardCharsets.UTF_8) ?.use { it.readText() } .orEmpty() val payload = if (text.isBlank()) JSONObject() else JSONObject(text) if (code !in 200..299 || !payload.optBoolean("ok", false)) { error(payload.optString("error", "Approval Hub HTTP $code")) } return payload } finally { connection.disconnect() } } fun fetchApprovals(): List { val rows = request("/api/v1/approvals").optJSONArray("approvals") ?: JSONArray() return buildList { for (index in 0 until rows.length()) { add(parseApproval(rows.getJSONObject(index))) } } } fun submitDecision( approvalId: String, decision: String, note: String ): LiveApproval { val body = JSONObject() .put("decision", decision) .put("note", note) .put("decided_by", "Ray via SHiRE Mobile") val response = request( "/api/v1/approvals/$approvalId/decision", method = "POST", body = body ) return parseApproval(response.getJSONObject("approval")) } fun createFileLink( approvalId: String, fileId: String ): String { val body = JSONObject().put("lifetime_seconds", 600) return request( "/api/v1/approvals/$approvalId/files/$fileId/link", method = "POST", body = body ).getString("url") } private fun parseApproval(row: JSONObject): LiveApproval { return LiveApproval( id = row.getString("id"), title = row.optString("title", "Approval required"), stage = row.optString("stage", "Human review"), purpose = row.optString("purpose"), affects = row.optString("affects"), risk = row.optString("risk"), urgency = row.optString("urgency", "Normal"), estimate = row.optString("estimate"), rollback = row.optString("rollback"), status = row.optString("status", "Pending"), createdAt = row.optString("created_at"), evidenceRoot = row.optString("evidence_root"), reportSha256 = row.optString("report_sha256"), decisionNote = row.optString("decision_note").takeIf { it.isNotBlank() }, permissionsRequested = row.optJSONArray("permissions_requested") .toStringList(), permissionsNotGranted = row.optJSONArray("permissions_not_granted") .toStringList(), files = row.optJSONArray("files").toFileList() ) } private fun JSONArray?.toStringList(): List { if (this == null) return emptyList() return buildList { for (index in 0 until length()) { add(optString(index)) } } } private fun JSONArray?.toFileList(): List { if (this == null) return emptyList() return buildList { for (index in 0 until length()) { val file = getJSONObject(index) add( LiveApprovalFile( id = file.getString("id"), name = file.optString("name"), sizeHuman = file.optString("size_human"), sha256 = file.optString("sha256"), mimeType = file.optString( "mime_type", "application/octet-stream" ), isEvidenceReport = file.optBoolean( "is_evidence_report", false ) ) ) } } } } internal object ApprovalNotifications { private const val CHANNEL_ID = "shire_approval_requests" private const val CHANNEL_NAME = "SHiRE Approvals" fun ensureChannel(context: Context) { val manager = context.getSystemService(NotificationManager::class.java) val channel = NotificationChannel( CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH ).apply { description = "Private SHiRE approval requests requiring Ray's review" } manager.createNotificationChannel(channel) } fun checkNow(context: Context) { Thread { runCatching { notifyPending(context, ApprovalApi.fetchApprovals()) } }.start() } fun notifyPending( context: Context, approvals: List ) { ensureChannel(context) if ( Build.VERSION.SDK_INT >= 33 && context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { return } val preferences = context.getSharedPreferences( "shire_approval_notifications", Context.MODE_PRIVATE ) val manager = context.getSystemService(NotificationManager::class.java) approvals .filter { it.status == "Pending" } .forEach { approval -> if (preferences.getBoolean(approval.id, false)) return@forEach val deepLink = Uri.parse("shire://approvals/${approval.id}") val intent = Intent( Intent.ACTION_VIEW, deepLink, context, ApprovalInboxActivity::class.java ) val pendingIntent = PendingIntent.getActivity( context, approval.id.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = android.app.Notification.Builder( context, CHANNEL_ID ) .setSmallIcon(R.drawable.shire_icon_monochrome) .setContentTitle("Approval required: ${approval.title}") .setContentText( "SHiRE has files and evidence ready for your review." ) .setStyle( android.app.Notification.BigTextStyle().bigText( "${approval.stage}. Tap to inspect the private " + "evidence package and record your decision." ) ) .setContentIntent(pendingIntent) .setAutoCancel(true) .setCategory(android.app.Notification.CATEGORY_STATUS) .build() manager.notify(approval.id.hashCode(), notification) preferences.edit().putBoolean(approval.id, true).apply() } } } class ApprovalPollJobService : JobService() { override fun onStartJob(params: JobParameters): Boolean { Thread { try { ApprovalNotifications.notifyPending( this, ApprovalApi.fetchApprovals() ) } finally { jobFinished(params, false) } }.start() return true } override fun onStopJob(params: JobParameters): Boolean = true companion object { private const val JOB_ID = 0x53484952 fun schedule(context: Context) { val scheduler = context.getSystemService(JobScheduler::class.java) val component = ComponentName( context, ApprovalPollJobService::class.java ) val job = JobInfo.Builder(JOB_ID, component) .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) .setPeriodic(15 * 60 * 1000L) .setPersisted(true) .build() scheduler.schedule(job) } } } @Composable private fun ApprovalInboxScreen( requestedApprovalId: String?, onClose: () -> Unit ) { val context = LocalContext.current val scope = rememberCoroutineScope() var approvals by remember { mutableStateOf>(emptyList()) } var selectedId by remember { mutableStateOf(requestedApprovalId) } var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } var refreshKey by remember { mutableIntStateOf(0) } LaunchedEffect(refreshKey, requestedApprovalId) { loading = true error = null runCatching { withContext(Dispatchers.IO) { ApprovalApi.fetchApprovals() } }.onSuccess { loaded -> approvals = loaded val requested = requestedApprovalId ?.takeIf { id -> loaded.any { it.id == id } } selectedId = requested ?: selectedId?.takeIf { id -> loaded.any { it.id == id } } ?: loaded.firstOrNull { it.status == "Pending" }?.id ?: loaded.firstOrNull()?.id ApprovalNotifications.notifyPending(context, loaded) }.onFailure { error = it.message ?: "Unable to reach SHiRE Approval Hub" } loading = false } val selected = approvals.firstOrNull { it.id == selectedId } Column( modifier = Modifier .fillMaxSize() .background(ApprovalBackground) .padding(16.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( "SHiRE APPROVAL INBOX", fontSize = 23.sp, fontWeight = FontWeight.Black ) Text( "${approvals.count { it.status == "Pending" }} pending • " + "Private Tailscale channel", color = ApprovalAmber, fontSize = 12.sp ) } TextButton(onClick = { refreshKey += 1 }) { Text("REFRESH") } TextButton(onClick = onClose) { Text("CLOSE") } } Spacer(Modifier.height(12.dp)) if (loading && approvals.isEmpty()) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { CircularProgressIndicator() Spacer(Modifier.height(12.dp)) Text("Loading private approval packages…") } return@Column } error?.let { Card( colors = CardDefaults.cardColors( containerColor = ApprovalRed.copy(alpha = 0.16f) ) ) { Text( it, modifier = Modifier.padding(14.dp), color = ApprovalRed ) } Spacer(Modifier.height(10.dp)) } if (approvals.isEmpty()) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = ApprovalSurface) ) { Column(Modifier.padding(18.dp)) { Text( "NO APPROVALS WAITING", fontWeight = FontWeight.Black, color = ApprovalGreen ) Text( "SHiRE will notify you when a real human-review " + "boundary is reached.", color = ApprovalMuted ) } } return@Column } BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val expanded = maxWidth >= 760.dp if (expanded) { Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp) ) { ApprovalList( approvals = approvals, selectedId = selectedId, onSelect = { selectedId = it }, modifier = Modifier .weight(0.82f) .fillMaxHeight() ) ApprovalDetails( approval = selected, modifier = Modifier .weight(1.18f) .fillMaxHeight(), onDecision = { decision, note -> scope.launch { runCatching { withContext(Dispatchers.IO) { ApprovalApi.submitDecision( selected!!.id, decision, note ) } }.onSuccess { refreshKey += 1 }.onFailure { error = it.message } } }, onOpenFile = { file -> scope.launch { runCatching { withContext(Dispatchers.IO) { ApprovalApi.createFileLink( selected!!.id, file.id ) } }.onSuccess { url -> context.startActivity( Intent( Intent.ACTION_VIEW, Uri.parse(url) ) ) }.onFailure { error = it.message } } } ) } } else { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { ApprovalList( approvals = approvals, selectedId = selectedId, onSelect = { selectedId = it }, modifier = Modifier .fillMaxWidth() .heightIn(max = 250.dp) ) ApprovalDetails( approval = selected, modifier = Modifier .fillMaxWidth() .weight(1f), onDecision = { decision, note -> scope.launch { runCatching { withContext(Dispatchers.IO) { ApprovalApi.submitDecision( selected!!.id, decision, note ) } }.onSuccess { refreshKey += 1 }.onFailure { error = it.message } } }, onOpenFile = { file -> scope.launch { runCatching { withContext(Dispatchers.IO) { ApprovalApi.createFileLink( selected!!.id, file.id ) } }.onSuccess { url -> context.startActivity( Intent( Intent.ACTION_VIEW, Uri.parse(url) ) ) }.onFailure { error = it.message } } } ) } } } } } @Composable private fun ApprovalList( approvals: List, selectedId: String?, onSelect: (String) -> Unit, modifier: Modifier = Modifier ) { Card( modifier = modifier, colors = CardDefaults.cardColors(containerColor = ApprovalSurface), shape = RoundedCornerShape(18.dp) ) { LazyColumn( modifier = Modifier .fillMaxSize() .padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(approvals, key = { it.id }) { approval -> val selected = approval.id == selectedId OutlinedButton( onClick = { onSelect(approval.id) }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.outlinedButtonColors( containerColor = if (selected) { ApprovalPurple.copy(alpha = 0.20f) } else { ApprovalRaised }, contentColor = ApprovalText ) ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start ) { Text( approval.title, fontWeight = FontWeight.Bold ) Text( "${approval.stage} • ${approval.status}", color = statusColor(approval.status), fontSize = 11.sp ) } } } } } } @Composable private fun ApprovalDetails( approval: LiveApproval?, modifier: Modifier = Modifier, onDecision: (String, String) -> Unit, onOpenFile: (LiveApprovalFile) -> Unit ) { Card( modifier = modifier, colors = CardDefaults.cardColors(containerColor = ApprovalSurface), shape = RoundedCornerShape(18.dp) ) { if (approval == null) { Text( "Select an approval package.", modifier = Modifier.padding(18.dp), color = ApprovalMuted ) return@Card } var reviewNote by remember(approval.id) { mutableStateOf("") } Column( modifier = Modifier .fillMaxWidth() .fillMaxHeight() .verticalScroll(rememberScrollState()) .padding(18.dp) ) { Text( approval.title, fontSize = 21.sp, fontWeight = FontWeight.Black ) Text( "${approval.status} • ${approval.urgency} urgency", color = statusColor(approval.status), fontWeight = FontWeight.Bold ) Spacer(Modifier.height(12.dp)) Detail("STAGE", approval.stage, ApprovalAmber) Detail("WHY APPROVAL IS REQUIRED", approval.purpose, ApprovalText) Detail("WHAT IT AFFECTS", approval.affects, ApprovalBlue) Detail("RISK", approval.risk, ApprovalRed) Detail("ESTIMATED REVIEW", approval.estimate, ApprovalGreen) Detail("ROLLBACK", approval.rollback, ApprovalMuted) Detail("CREATED", approval.createdAt, ApprovalMuted) Detail("REPORT SHA-256", approval.reportSha256, ApprovalMuted) Text( "REQUESTED PERMISSIONS", color = ApprovalMuted, fontSize = 10.sp, fontWeight = FontWeight.Black ) approval.permissionsRequested.forEach { Text("• $it", fontSize = 12.sp) } Spacer(Modifier.height(8.dp)) Text( "NOT GRANTED BY THIS APPROVAL", color = ApprovalRed, fontSize = 10.sp, fontWeight = FontWeight.Black ) approval.permissionsNotGranted.forEach { Text("• $it", fontSize = 12.sp, color = ApprovalRed) } Spacer(Modifier.height(12.dp)) HorizontalDivider() Spacer(Modifier.height(12.dp)) Text( "FILES & EVIDENCE (${approval.files.size})", fontWeight = FontWeight.Black, color = ApprovalGreen ) approval.files.forEach { file -> Card( modifier = Modifier .fillMaxWidth() .padding(top = 7.dp), colors = CardDefaults.cardColors(containerColor = ApprovalRaised) ) { Column(Modifier.padding(12.dp)) { Text(file.name, fontWeight = FontWeight.Bold) Text( "${file.sizeHuman} • ${file.mimeType}", color = ApprovalMuted, fontSize = 11.sp ) Text( "SHA-256 ${file.sha256}", color = ApprovalMuted, fontSize = 9.sp, lineHeight = 12.sp ) TextButton(onClick = { onOpenFile(file) }) { Text( if (file.isEvidenceReport) { "OPEN EVIDENCE" } else { "OPEN / SAVE COPY" } ) } } } } approval.decisionNote?.let { Spacer(Modifier.height(12.dp)) Detail("RECORDED NOTE", it, ApprovalText) } if (approval.status == "Pending") { Spacer(Modifier.height(14.dp)) OutlinedTextField( value = reviewNote, onValueChange = { reviewNote = it.take(2000) }, modifier = Modifier.fillMaxWidth(), label = { Text("Review note or requested changes") }, minLines = 2, maxLines = 5 ) Spacer(Modifier.height(10.dp)) Button( onClick = { onDecision("approve", reviewNote) }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = ApprovalGreen, contentColor = Color.Black ) ) { Text("APPROVE THIS EVIDENCE", fontWeight = FontWeight.Black) } Spacer(Modifier.height(7.dp)) OutlinedButton( onClick = { onDecision("request_changes", reviewNote) }, modifier = Modifier.fillMaxWidth() ) { Text("REQUEST CHANGES") } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(7.dp) ) { OutlinedButton( onClick = { onDecision("defer", reviewNote) }, modifier = Modifier.weight(1f) ) { Text("DEFER") } OutlinedButton( onClick = { onDecision("reject", reviewNote) }, modifier = Modifier.weight(1f) ) { Text("REJECT", color = ApprovalRed) } } Spacer(Modifier.height(8.dp)) Text( "Approval is explicit and applies only to this exact " + "evidence package. Forge and printing remain locked.", color = ApprovalAmber, fontSize = 11.sp ) } } } } @Composable private fun Detail( label: String, value: String, color: Color ) { Text( label, color = ApprovalMuted, fontSize = 10.sp, fontWeight = FontWeight.Black ) Text( value.ifBlank { "Not supplied" }, color = color, fontSize = 13.sp, lineHeight = 18.sp ) Spacer(Modifier.height(9.dp)) } private fun statusColor(status: String): Color { return when (status) { "Approved" -> ApprovalGreen "Rejected" -> ApprovalRed "Changes Requested" -> ApprovalAmber "Deferred" -> ApprovalBlue else -> ApprovalAmber } }