The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
8.3 KiB
Kotlin
198 lines
8.3 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.Manifest
|
|
import android.content.pm.PackageManager
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
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.width
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.core.net.toUri
|
|
import com.example.wgapplink.EnrollmentScanActivity
|
|
import com.google.zxing.client.android.Intents
|
|
import com.journeyapps.barcodescanner.ScanContract
|
|
import com.journeyapps.barcodescanner.ScanIntentResult
|
|
import com.journeyapps.barcodescanner.ScanOptions
|
|
|
|
/**
|
|
* Server address and token. The normal path is the "Scan QR code" button below, which decodes the
|
|
* server's terminal QR itself; these fields are the fallback for typing the same three values by
|
|
* hand. [onBack] is null on first run, when there is nothing to go back to.
|
|
*/
|
|
@Composable
|
|
fun SettingsScreen(
|
|
existing: ServerSettings?,
|
|
onSaved: (ServerSettings) -> Unit,
|
|
onBack: (() -> Unit)?,
|
|
) {
|
|
val context = LocalContext.current
|
|
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
|
|
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
|
|
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
|
|
// back off the device.
|
|
var token by remember { mutableStateOf("") }
|
|
var error by remember { mutableStateOf<String?>(null) }
|
|
|
|
val scanLauncher =
|
|
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
|
|
// Null contents means the user backed out of the scanner -- not an error.
|
|
val contents = result.contents ?: return@rememberLauncherForActivityResult
|
|
val settings = parseEnrollmentUri(contents.toUri())
|
|
if (settings == null) {
|
|
error = "Not a valid enrollment code"
|
|
} else {
|
|
saveServerSettings(context, settings)
|
|
onSaved(settings)
|
|
}
|
|
}
|
|
|
|
val requestCamera =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
if (granted) {
|
|
scanLauncher.launch(enrollmentScanOptions())
|
|
} else {
|
|
error =
|
|
"Scanning needs the camera. Grant it in the system settings, " +
|
|
"or type the host, port and token in below."
|
|
}
|
|
}
|
|
|
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
// Leading, where a back arrow points at what it returns to. Trailing it would put a
|
|
// left-pointing arrow at the right edge, aimed across the title it sits beside.
|
|
//
|
|
// Absent rather than disabled on first run, which is the one place this app lets a
|
|
// control come and go: there is no screen underneath yet, so a Back here would not be a
|
|
// capability being withheld but a promise it could not keep.
|
|
if (onBack != null) {
|
|
GlyphButton(BACK_GLYPH, "Back", onBack)
|
|
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
|
}
|
|
Text(
|
|
"Server",
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
"The easy way: run ai-server on the backend and scan the QR it prints. " +
|
|
"Or type the same values here.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
OutlinedButton(
|
|
onClick = {
|
|
// Hold the camera permission before the scanner starts. Letting its activity ask on
|
|
// our behalf is what the library does by default, and it opens the camera without
|
|
// waiting for the answer: the first-ever scan comes up as a live preview with
|
|
// "Sorry, the Android camera encountered a problem" over it, and works on the
|
|
// second try.
|
|
if (
|
|
context.checkSelfPermission(Manifest.permission.CAMERA) ==
|
|
PackageManager.PERMISSION_GRANTED
|
|
) {
|
|
scanLauncher.launch(enrollmentScanOptions())
|
|
} else {
|
|
requestCamera.launch(Manifest.permission.CAMERA)
|
|
}
|
|
},
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text("Scan QR code")
|
|
}
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
OutlinedTextField(
|
|
value = host,
|
|
onValueChange = { host = it },
|
|
label = { Text("Host") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = port,
|
|
onValueChange = { port = it },
|
|
label = { Text("Port") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = token,
|
|
onValueChange = { token = it },
|
|
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
)
|
|
Spacer(Modifier.height(24.dp))
|
|
|
|
error?.let {
|
|
Text(it, color = MaterialTheme.colorScheme.error)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
|
|
Button(
|
|
onClick = {
|
|
val portNumber = port.trim().toIntOrNull()
|
|
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
|
|
when {
|
|
host.isBlank() -> error = "Host is required"
|
|
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
|
|
effectiveToken.isEmpty() ->
|
|
error = "Token is required -- scan the server's QR or paste it"
|
|
else -> {
|
|
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
|
|
saveServerSettings(context, settings)
|
|
onSaved(settings)
|
|
}
|
|
}
|
|
}
|
|
) {
|
|
Text("Save")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
|
|
* button when the camera permission is already held, and from the permission result when it has
|
|
* just been granted.
|
|
*
|
|
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
|
|
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
|
|
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
|
|
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
|
|
* polarity.
|
|
*/
|
|
private fun enrollmentScanOptions(): ScanOptions =
|
|
ScanOptions()
|
|
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
|
.setCaptureActivity(EnrollmentScanActivity::class.java)
|
|
// Follow the phone, not the library's landscape pin.
|
|
.setOrientationLocked(false)
|
|
.addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
|