Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ android {
applicationId = "com.hermes.client"
minSdk = 26
targetSdk = 37
versionCode = 54
versionName = "0.1.50"
versionCode = 55
versionName = "0.1.51"
testInstrumentationRunner = "com.hermes.client.HiltTestRunner"
// App name; the beta build type overrides this so both can be installed at once.
manifestPlaceholders["appLabel"] = "Hermes"
Expand Down Expand Up @@ -107,6 +107,8 @@ dependencies {
implementation(libs.datastore.preferences)
implementation(libs.security.crypto)
implementation(libs.markdown.m3)
implementation(libs.zxing.embedded)
implementation(libs.glance.appwidget)

debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
Expand All @@ -17,6 +20,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand All @@ -28,6 +32,12 @@
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="hermes" />
</intent-filter>
</activity>

<service
Expand All @@ -47,6 +57,16 @@
<receiver
android:name=".notifications.NotificationActionReceiver"
android:exported="false" />
<receiver
android:name=".widget.HermesWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/hermes_widget_info" />
</receiver>

<provider
android:name="androidx.core.content.FileProvider"
Expand Down
50 changes: 46 additions & 4 deletions app/src/main/java/com/hermes/client/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import com.hermes.client.data.repository.SettingsStore
import com.hermes.client.data.repository.ThemeMode
import com.hermes.client.ui.diagnostics.CrashReportScreen
import com.hermes.client.ui.nav.HermesNav
import com.hermes.client.ui.nav.deepLinkRouteFor
import com.hermes.client.ui.nav.isNewChatLink
import com.hermes.client.ui.theme.HermesTheme
import com.hermes.client.ui.theme.LocalToolCallTechnical
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -48,11 +50,20 @@ class MainActivity : ComponentActivity() {
* already running still navigates; consumed by `HermesNav`'s `deepLinkRoute` param.
*/
private var pendingRoute = mutableStateOf<String?>(null)
private val newChatInFlight = java.util.concurrent.atomic.AtomicBoolean(false)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingRoute.value = intent?.getStringExtra("extra_route")
intent?.removeExtra("extra_route")
val dlData = intent?.data
if (dlData != null && isNewChatLink(dlData.toString())) {
openNewChat()
intent?.data = null
} else {
pendingRoute.value = intent?.getStringExtra("extra_route")
?: dlData?.let { deepLinkRouteFor(it.toString()) }
intent?.removeExtra("extra_route")
intent?.data = null
}
handleShare(intent)
val hasConfig = credentialStore.load() != null
val crashReport = CrashReporter.read(this)
Expand Down Expand Up @@ -120,8 +131,16 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
pendingRoute.value = intent.getStringExtra("extra_route")
intent.removeExtra("extra_route")
val dlData = intent.data
if (dlData != null && isNewChatLink(dlData.toString())) {
openNewChat()
intent.data = null
} else {
pendingRoute.value = intent.getStringExtra("extra_route")
?: dlData?.let { deepLinkRouteFor(it.toString()) }
intent.removeExtra("extra_route")
intent.data = null
}
handleShare(intent)
}

Expand All @@ -135,6 +154,29 @@ class MainActivity : ComponentActivity() {
startActivity(Intent.createChooser(intent, "Share crash report"))
}

/** Create a fresh chat and navigate to it (widget "New chat" / hermes://new). No-op if unconfigured. */
private fun openNewChat() {
if (credentialStore.load() == null) return
if (!newChatInFlight.compareAndSet(false, true)) return // a create is already running — ignore repeat taps
lifecycleScope.launch {
try {
runCatching {
chat.connect() // idempotent; a cold start has no socket yet
profileManager.refresh() // load active profile so the session isn't orphaned to default
chat.createSession(profileManager.active.value)
}.onSuccess { id -> pendingRoute.value = "chat/$id" }
.onFailure { e ->
if (e is kotlinx.coroutines.CancellationException) throw e
android.widget.Toast.makeText(
this@MainActivity, "Couldn't start a chat", android.widget.Toast.LENGTH_SHORT,
).show()
}
} finally {
newChatInFlight.set(false)
}
}
}

/**
* Handle an incoming ACTION_SEND share (text or a single image): open a new chat with the text
* pre-filled and/or the image attached. Reuses the notification deep-link rail.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.hermes.client.data.audio

/** Build a base64 data URL the gateway's transcribe endpoint accepts: data:<mime>;base64,<b64>. */
fun audioDataUrl(bytes: ByteArray, mime: String): String =
"data:$mime;base64," + java.util.Base64.getEncoder().encodeToString(bytes)
75 changes: 75 additions & 0 deletions app/src/main/java/com/hermes/client/data/audio/AudioRecorder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.hermes.client.data.audio

import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import java.io.File

/** A captured voice note. */
data class Recording(val bytes: ByteArray, val mime: String) {
override fun equals(other: Any?) =
other is Recording && mime == other.mime && bytes.contentEquals(other.bytes)
override fun hashCode() = 31 * bytes.contentHashCode() + mime.hashCode()
}

/** Records a single voice note. Interface so RecordTaskViewModel is testable with a fake. */
interface AudioRecorder {
fun start()
fun stop(): Recording?
fun cancel()
}

/** MediaRecorder-backed recorder writing audio/mp4 (AAC) to an app-cache temp file. */
class MediaAudioRecorder(private val context: Context) : AudioRecorder {
private var recorder: MediaRecorder? = null
private var outputFile: File? = null

override fun start() {
if (recorder != null) return
val file = File.createTempFile("rec_", ".m4a", context.cacheDir)
val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context)
else @Suppress("DEPRECATION") MediaRecorder()
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioEncodingBitRate(96_000)
rec.setAudioSamplingRate(44_100)
rec.setOutputFile(file.absolutePath)
try {
rec.prepare()
rec.start()
} catch (e: Exception) {
runCatching { rec.release() }
file.delete()
throw e
}
recorder = rec
outputFile = file
}

override fun stop(): Recording? {
val rec = recorder ?: return null
val file = outputFile
recorder = null
outputFile = null
val stopped = runCatching { rec.stop() }.isSuccess
runCatching { rec.release() }
if (!stopped || file == null || !file.exists() || file.length() == 0L) {
file?.delete()
return null
}
val bytes = runCatching { file.readBytes() }.getOrNull()
file.delete()
return bytes?.let { Recording(it, "audio/mp4") }
}

override fun cancel() {
val rec = recorder ?: return
val file = outputFile
recorder = null
outputFile = null
runCatching { rec.stop() }
runCatching { rec.release() }
file?.delete()
}
}
21 changes: 11 additions & 10 deletions app/src/main/java/com/hermes/client/data/network/GatedAuth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ class GatedAuth(
put("username", cfg.username)
put("password", cfg.password)
}
val req = Request.Builder()
.url("${cfg.baseUrl.trimEnd('/')}/auth/password-login")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
val ok = runCatching { loginClient.newCall(req).execute().use { it.isSuccessful } }
.getOrDefault(false)
val ok = runCatching {
val req = Request.Builder()
.url("${cfg.baseUrl.trimEnd('/')}/auth/password-login")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
loginClient.newCall(req).execute().use { it.isSuccessful }
}.getOrDefault(false)
DebugLog.log("ws", "gated login -> $ok")
return ok
}
Expand Down Expand Up @@ -108,11 +109,11 @@ class GatedAuth(
put("username", username)
put("password", password)
}
val req = Request.Builder()
.url("${baseUrl.trimEnd('/')}/auth/password-login")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
return runCatching {
val req = Request.Builder()
.url("${baseUrl.trimEnd('/')}/auth/password-login")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
OkHttpClient().newCall(req).execute().use { it.isSuccessful }
}.getOrDefault(false)
}
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.hermes.client.data.network

/**
* Backend health, distinct from the WebSocket [ConnectionState]. Sourced from the device's
* connectivity plus the gateway's public `/api/status`.
*/
sealed interface GatewayHealth {
/** Before the first probe completes — renders nothing. */
data object Unknown : GatewayHealth

/** `/api/status` returned 2xx. [running] mirrors `gateway_running`. */
data class Healthy(val version: String?, val running: Boolean, val latencyMs: Long?) : GatewayHealth

/** The device has no network — the phone is offline, not the gateway. */
data object DeviceOffline : GatewayHealth

/** Network is up but `/api/status` failed (timeout, refused, non-2xx). */
data class GatewayUnreachable(val detail: String?) : GatewayHealth
}

/** True when the down-strip and the You-tab badge should show. */
fun GatewayHealth.isUnhealthy(): Boolean =
this is GatewayHealth.DeviceOffline || this is GatewayHealth.GatewayUnreachable
Loading
Loading