shared/ carried the vendored phone app's tree behind a 30-entry
exclude list. Deleted the 46 phone-only files (recording service,
widgets, Room DB, Nextcloud/local-only/folder-sync providers, TOFU
trust, Android settings stores, phone UI screens) and kept only the
seven the desktop compiles: CustomShonarProvider, ProviderTypes,
ShonarProvider, ShonarAuthStore, ShonarHandshake, ProviderRegistry,
AiContent. ProviderRegistry reduces to its id constant; handshake
drops the TOFU param; provider defaults self-identify as desktop.
Gradle exclude list gone — srcDir('../shared') is pure desktop now.
89 lines
3.8 KiB
Kotlin
89 lines
3.8 KiB
Kotlin
package com.shonar.provider
|
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import okhttp3.OkHttpClient
|
|
import okhttp3.Request
|
|
import java.util.concurrent.TimeUnit
|
|
import javax.net.ssl.SSLHandshakeException
|
|
|
|
/**
|
|
* Unauthenticated handshake against the SHONAR backend's
|
|
* GET /api/v1/provider-info. Used by the provider-selection screen and by
|
|
* Start9/Umbrel platform probes to identify SHONAR-compatible services.
|
|
*
|
|
* TLS policy (docs/server-providers.md §4):
|
|
* - full system verification by default, no bypass, ever.
|
|
* - a handshake failure yields ProbeResult.TlsFailure with the peer cert's
|
|
* SPKI SHA-256 (recorded by the TOFU trust manager) so the UI can run
|
|
* explicit trust-on-first-use approval; this client NEVER retries with
|
|
* verification disabled.
|
|
*/
|
|
class ShonarHandshake(
|
|
private val client: OkHttpClient = defaultClient(),
|
|
) {
|
|
|
|
suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) {
|
|
val endpoint = url.origin + "/api/v1/provider-info"
|
|
val request = Request.Builder().url(endpoint).get().build()
|
|
try {
|
|
client.newCall(request).execute().use { resp ->
|
|
if (resp.code == 200) {
|
|
parseBody(resp.body?.string().orEmpty())
|
|
} else {
|
|
ProbeResult.Incompatible
|
|
}
|
|
}
|
|
} catch (e: SSLHandshakeException) {
|
|
ProbeResult.TlsFailure(fingerprintFor(url.host))
|
|
} catch (e: javax.net.ssl.SSLPeerUnverifiedException) {
|
|
// Hostname mismatch: strict verifier stays strict, but the UI
|
|
// should still say *why* instead of a generic network error.
|
|
ProbeResult.TlsFailure(fingerprintFor(url.host))
|
|
} catch (e: Exception) {
|
|
// message must stay generic: exception text can contain URLs but
|
|
// never credentials (this call sends no credentials at all)
|
|
ProbeResult.NetworkError(e.javaClass.simpleName)
|
|
}
|
|
}
|
|
|
|
private fun parseBody(json: String): ProbeResult = try {
|
|
val root = org.json.JSONObject(json)
|
|
if (root.optString("kind") != "shonar") {
|
|
ProbeResult.Incompatible
|
|
} else {
|
|
val caps = root.optJSONObject("capabilities") ?: org.json.JSONObject()
|
|
val set = mutableSetOf<ProviderDescriptor.Capability>()
|
|
if (caps.optBoolean("chunked_upload")) set += ProviderDescriptor.Capability.CHUNKED_UPLOAD
|
|
if (caps.optBoolean("server_transcription")) set += ProviderDescriptor.Capability.SERVER_TRANSCRIPTION
|
|
if (caps.optBoolean("server_summary")) set += ProviderDescriptor.Capability.SERVER_SUMMARY
|
|
if (caps.optBoolean("account_deletion")) set += ProviderDescriptor.Capability.ACCOUNT_DELETION
|
|
ProbeResult.Compatible(
|
|
descriptor = ProviderDescriptor(
|
|
id = ProviderRegistry.CUSTOM_SHONAR_ID,
|
|
displayName = "Custom SHONAR server",
|
|
capabilities = set,
|
|
),
|
|
serverName = root.optString("storage_backend", "server"),
|
|
version = root.optString("version", "unknown"),
|
|
)
|
|
}
|
|
} catch (e: Exception) {
|
|
ProbeResult.Incompatible
|
|
}
|
|
|
|
/**
|
|
* SPKI SHA-256 captured during the failed handshake. The desktop talks
|
|
* to a local engine over plain HTTP, so this is a placeholder kept for
|
|
* the ProbeResult contract.
|
|
*/
|
|
private fun fingerprintFor(host: String): String = "unknown"
|
|
|
|
companion object {
|
|
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
|
.connectTimeout(10, TimeUnit.SECONDS)
|
|
.readTimeout(15, TimeUnit.SECONDS)
|
|
.followRedirects(false) // do not silently follow redirects to other hosts
|
|
.build()
|
|
}
|
|
}
|