chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:00 +08:00
commit 3de48288cb
2986 changed files with 1131193 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# ktlint reads EditorConfig for Kotlin style settings.
# https://pinterest.github.io/ktlint/latest/rules/standard/
root = true
[*.{kt,kts}]
max_line_length = 100
indent_size = 4
indent_style = space
ktlint_standard = enabled
+13
View File
@@ -0,0 +1,13 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
*.apk
*.aab
*.keystore
!debug.keystore
+76
View File
@@ -0,0 +1,76 @@
# Omnigent Android
Thin Kotlin/`WebView` shell for Omnigent. Like the Electron app and the iOS
shell (`web/ios`), this target loads the server-served web UI instead of
shipping a duplicate copy of the SPA. It is a native _shell_, not a rewrite.
## Development
Open `web/android` in Android Studio (Ladybug / AGP 8.6+) and run the `app`
configuration on an API 34/35 emulator. Requires JDK 17 and the Android SDK
(`compileSdk 35`, `minSdk 28`).
Debug builds permit cleartext (`http://`) to localhost and private-range hosts
via `res/xml/network_security_config.xml` for local development; release builds
keep the platform default (HTTPS only), mirroring the iOS
`NSAllowsArbitraryLoadsInWebContent` debug-only posture.
## How it relates to the web bundle
The same `web/` bundle runs in a browser tab, the Electron shell, the iOS
WKWebView shell, and this Android WebView. Detection is feature-based at
runtime via `window.omnigentNative` — see `web/src/lib/nativeBridge.ts`. This
shell injects that object with `kind: "android"`; the web layer needs no
per-feature branching beyond the `kind` discriminator (`isAndroidShell()`).
The web→native transport is a `WebViewCompat.addWebMessageListener` channel
(`OmnigentBridgeListener`) **origin-allowlisted to the pinned server** and
gated on `isMainFrame`, rather than `addJavascriptInterface`. This is the
structural equivalent of the iOS bridge's frame-origin + `isMainFrame` check:
the transport object is never delivered to a sandboxed / cross-origin
agent-HTML iframe, so an injected artifact can't reach the native surface.
## Scope (first version)
Provides native setup chrome (server entry + recent servers via
`ConnectActivity`), `WebView` loading, foreground local notifications with tap
routing back into the SPA, a best-effort app badge, edge-to-edge inset plumbing
(measured insets injected as `--omnigent-android-safe-area-*`, consumed by the
web inset system), correct system-back / predictive-back handling, file
downloads — including `blob:` / `data:` exports via a fetch→base64→MediaStore
bridge, which closes omnigent-ai/omnigent#969 (the iOS shell drops these) —
file **uploads** (`<input type=file>` via `WebChromeClient.onShowFileChooser`),
and **microphone** capture for voice input (`onPermissionRequest`, granted to
the pinned origin only, with a runtime `RECORD_AUDIO` request).
### Deliberately deferred to the web in-page fallbacks
These are iOS-only native chrome; the SPA already renders its own equivalents
when the bridge methods are absent, so the Android shell omits them for now:
- **Interactive sidebar edge-swipe drawer.** Not portable: on Android 10+ the
system back gesture owns both screen edges, and
`View.setSystemGestureExclusionRects()` does not apply to it. The sidebar
opens from the in-page hamburger, exactly as in a browser tab.
- **Native floating server switcher** and **Chat/Terminal bar.** Rendered
in-page by the SPA.
### Known parity gaps
- **App badge count.** Android has no universal numeric badge API. We set
`NotificationCompat.setNumber()` (shown by some launchers; AOSP/Pixel shows
only a dot) and treat the notification dot as the guaranteed surface.
`setBadgeCount(0)` is a no-op — we do not cancel notifications to clear a
badge.
## Distribution
Gradle assembles a release APK/AAB; `fastlane` (Android) automates signing and
upload. Google Play restricts "WebView of a website" apps, so the initial
channel is direct APK / F-Droid; a user-configured server client is a stronger
Play case but review is unpredictable for this category.
> Status: builds clean — `gradlew :app:assembleDebug :app:lintDebug` produces a
> debug APK with 0 lint errors (JDK 17, Gradle 8.9 wrapper, `compileSdk 35`).
> Implementation for omnigent-ai/omnigent#1604; not yet exercised on a device
> (no runtime/instrumented testing here), so treat device behavior as unverified.
+46
View File
@@ -0,0 +1,46 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "ai.omnigent.android"
compileSdk = 35
defaultConfig {
applicationId = "ai.omnigent.android"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
buildFeatures {
buildConfig = true // for BuildConfig.DEBUG (gates authLog + WebView remote debugging)
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity)
implementation(libs.androidx.webkit)
}
+11
View File
@@ -0,0 +1,11 @@
# No app-specific keep rules are required.
#
# The web<->native bridge is a WebViewCompat.WebMessageListener
# (OmnigentBridgeListener), invoked through the AndroidX webkit library's own
# interface dispatch not reflection or @JavascriptInterface so R8 keeps it
# through ordinary reachability, and androidx.webkit ships consumer rules for
# the WebView-compat surface. NativeBridgeScript is a plain JS string constant
# with no reflective entry points.
#
# isMinifyEnabled = false today, so nothing here is active yet; this file is
# kept for whenever minification is enabled.
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug overlay: allow cleartext to local development hosts only. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<!-- Android emulator host loopback. -->
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Runtime-requested on API 33+; see notification flow. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Runtime-requested when the web UI first asks for the mic (voice input). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<!--
allowBackup=false: the WebView cookie store holds the authenticated
__Host-ap_session cookie, so cloud Auto Backup and `adb backup` would
otherwise copy a live session (and the saved server list) off-device. A
server URL is trivially re-entered; a session is not worth exfiltrating.
-->
<application
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Omnigent">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode|smallestScreenSize|screenLayout|density"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ConnectActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
</application>
</manifest>
@@ -0,0 +1,50 @@
package ai.omnigent.android
/**
* Android `WebView` hands `http(s)` downloads to `DownloadManager`, but
* `blob:` / `data:` URLs — how the agent's generated files come down — are
* dropped by default (the same gap that leaves omnigent-ai/omnigent#969
* unfixed on iOS). A blob URL is origin-scoped and only readable from page
* context, so we fetch it there, base64-encode it, and post it over the same
* origin-allowlisted bridge ([OmnigentBridgeListener]) for [BlobSaver] to write.
*
* This script is evaluated by the native side in the main frame only (from the
* download listener), so it runs with main-frame trust.
*/
object BlobDownloadScript {
/** JS that reads [url] (a blob:/data: URL) and posts it to the native side. */
fun fetchAsBase64(
url: String,
suggestedName: String,
): String {
val u = jsString(url)
val name = jsString(suggestedName)
return """
(() => {
fetch($u)
.then((r) => r.blob())
.then((blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve({ data: reader.result, type: blob.type });
reader.onerror = reject;
reader.readAsDataURL(blob);
}))
.then(({ data, type }) => {
// data is a data: URL: "data:<mime>;base64,<payload>".
const comma = data.indexOf(",");
const base64 = comma >= 0 ? data.slice(comma + 1) : data;
const bridge = window.${OmnigentBridgeListener.JS_OBJECT_NAME};
if (bridge) {
bridge.postMessage(JSON.stringify({
method: "blobBase64",
base64,
mimeType: type || "application/octet-stream",
name: $name,
}));
}
})
.catch(() => {});
})();
""".trimIndent()
}
}
@@ -0,0 +1,114 @@
package ai.omnigent.android
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Base64
import android.widget.Toast
import androidx.annotation.RequiresApi
import java.io.File
import java.util.concurrent.Executors
/**
* Decodes a base64 payload (produced by [BlobDownloadScript], dispatched by
* [OmnigentBridgeListener]) and writes it to the device's Downloads — MediaStore
* on API 29+, the app-specific external dir on 28 (both permission-free). The
* decode + write run on a worker so a large file never blocks the caller.
*
* Trust is enforced upstream by the bridge's origin allowlist + main-frame gate,
* so this class does no origin checking of its own.
*/
class BlobSaver(
private val context: Context,
) {
private val main = Handler(Looper.getMainLooper())
private val io = Executors.newSingleThreadExecutor()
/** Release the worker thread; call from the host's onDestroy. */
fun shutdown() {
io.shutdown()
}
fun save(
base64: String,
mimeType: String,
suggestedName: String,
) {
io.execute {
val bytes =
try {
Base64.decode(base64, Base64.DEFAULT)
} catch (_: Throwable) {
return@execute
}
val name = safeFileName(suggestedName)
val saved =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveViaMediaStore(name, mimeType, bytes)
} else {
saveToAppDownloads(name, bytes)
}
main.post {
val msg = if (saved) "Saved $name to Downloads" else "Couldn't save $name"
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveViaMediaStore(
name: String,
mimeType: String,
bytes: ByteArray,
): Boolean =
runCatching {
val resolver = context.contentResolver
val values =
ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val uri =
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: return false
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return false
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null)
true
}.getOrDefault(false)
private fun saveToAppDownloads(
name: String,
bytes: ByteArray,
): Boolean =
runCatching {
val dir =
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?: context.filesDir
File(dir, name).outputStream().use { it.write(bytes) }
true
}.getOrDefault(false)
private fun safeFileName(suggested: String): String {
// Basename past either separator style — a Windows-flavored "foo\bar.txt"
// suggestion should save as "bar.txt", not "foo_bar.txt".
val cleaned =
suggested
.substringAfterLast('/')
.substringAfterLast('\\')
.replace(Regex("[^A-Za-z0-9._-]"), "_")
// "" / "." / ".." aren't usable names — on the API 28 File path "." and
// ".." resolve to a directory, so the write would fail. Fall back instead.
return if (cleaned.isBlank() || cleaned == "." || cleaned == "..") {
"omnigent-${System.currentTimeMillis()}"
} else {
cleaned
}
}
}
@@ -0,0 +1,74 @@
package ai.omnigent.android
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
/**
* Native server-entry screen, the Android counterpart to the iOS `ConnectView`:
* a URL field plus a tappable recent-servers list. On connect it persists the
* server via [ServerStore] and hands off to [MainActivity].
*
* [MainActivity] routes here on launch when no server has been configured yet.
*/
class ConnectActivity : ComponentActivity() {
private val store by lazy { ServerStore(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_connect)
val field = findViewById<EditText>(R.id.server_url)
findViewById<Button>(R.id.connect).setOnClickListener { connect(field.text.toString()) }
field.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
connect(field.text.toString())
true
} else {
false
}
}
renderRecents()
}
private fun connect(input: String) {
val url = normalizeServerUrl(input)
val error = findViewById<TextView>(R.id.error_text)
if (url == null) {
error.setText(R.string.connect_invalid)
error.visibility = View.VISIBLE
return
}
error.visibility = View.GONE
store.connect(url)
startActivity(
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
},
)
finish()
}
private fun renderRecents() {
val recents = store.recentServers()
val container = findViewById<LinearLayout>(R.id.recents)
findViewById<TextView>(R.id.recents_label).visibility =
if (recents.isEmpty()) View.GONE else View.VISIBLE
val inflater = LayoutInflater.from(this)
for (url in recents) {
val row = inflater.inflate(R.layout.item_recent, container, false) as TextView
row.text = url
row.setOnClickListener { connect(url) }
container.addView(row)
}
}
}
@@ -0,0 +1,26 @@
package ai.omnigent.android
/**
* JSON-encode a string so it can be interpolated safely into JavaScript passed
* to `WebView.evaluateJavascript`. Escapes quotes, backslashes, newlines, `<`
* (so a `</script>`-style payload can't break out of an inline context), and the
* U+2028/U+2029 line terminators that are valid line breaks in a JS string but
* not in JSON. Matched by code point so no invisible characters live in source.
*/
fun jsString(value: String): String =
buildString {
append('"')
for (c in value) {
when (c.code) {
'"'.code -> append("\\\"")
'\\'.code -> append("\\\\")
'\n'.code -> append("\\n")
'\r'.code -> append("\\r")
'<'.code -> append("\\u003c")
0x2028 -> append("\\u2028")
0x2029 -> append("\\u2029")
else -> append(c)
}
}
append('"')
}
@@ -0,0 +1,12 @@
package ai.omnigent.android
import android.util.Log
/**
* Auth-flow trace, emitted only in debug builds. The login flow logs events
* (never the token) for diagnosis, but even event-level auth traces shouldn't
* ship in release logcat.
*/
fun authLog(message: String) {
if (BuildConfig.DEBUG) Log.i("OmnigentAuth", message)
}
@@ -0,0 +1,573 @@
package ai.omnigent.android
import android.Manifest
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.MimeTypeMap
import android.webkit.PermissionRequest
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
/**
* The single WebView host. Mirrors the iOS `WebShellView` + `OmnigentWebView`:
* loads the server-served SPA, installs the `window.omnigentNative` bridge, and
* wires the native capabilities the web layer expects.
*
* Server URL comes from [ServerStore]; when none is set yet, launch routes to
* [ConnectActivity] first. Sidebar edge-swipe is intentionally absent (README).
*/
class MainActivity : ComponentActivity() {
private lateinit var webView: WebView
private lateinit var notifications: NativeNotificationManager
private lateinit var blobSaver: BlobSaver
private val loginManager = OidcLoginManager()
private var pinnedOrigin: String? = null
// Bridge-dependent work deferred until the page (and its injected emit
// callbacks) exist — see onPageReady.
private var pendingNavigatePath: String? = null
private var lastInsets: Insets? = null
private var pageLoaded = false
private var loginAttempts = 0 // capped browser-login retries; reset in onPageReady
private var historyCleared = false // drop pre-auth/login-redirect history once
// WebChromeClient affordances that need Activity-scoped result launchers.
// Transient by design: rotation is covered by configChanges (no recreation),
// so the only loss is the process-death case (killed while the picker /
// permission dialog is foreground) — the re-delivered result finds a null
// field and the fresh page simply has no pending input. No hang or crash.
private var pendingFileCallback: ValueCallback<Array<Uri>>? = null
private var pendingMicRequest: PermissionRequest? = null
private val requestNotifications =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
// Granted or not: notify() no-ops when notifications are disabled and
// the web layer keeps working without OS toasts.
}
private val requestMic =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val request = pendingMicRequest
pendingMicRequest = null
if (granted && request != null) {
request.grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))
} else {
request?.deny()
}
}
private val pickFiles =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
val callback = pendingFileCallback
pendingFileCallback = null
callback?.onReceiveValue(uris.toTypedArray())
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Edge-to-edge: the WebView spans system bars; insets are pushed to CSS
// below. Display-cutout handling is set in the manifest theme.
WindowCompat.setDecorFitsSystemWindows(window, false)
val store = ServerStore(this)
if (!store.hasServer()) {
// No server configured yet — send the user to the connect screen first.
startActivity(Intent(this, ConnectActivity::class.java))
finish()
return
}
val serverUrl = store.currentServerUrl()
pinnedOrigin = originOf(serverUrl)
// Application context for the long-lived helpers so the WebView's bridge
// reference chain can't pin this Activity.
notifications = NativeNotificationManager(applicationContext)
blobSaver = BlobSaver(applicationContext)
// Capture (don't replay yet) a notification tap that cold-started us.
pendingNavigatePath = navigatePathOf(intent)
if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true) // chrome://inspect
webView =
WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
webViewClient =
OmnigentWebViewClient(
pinnedOrigin = { pinnedOrigin },
onPageReady = ::onPageReady,
onLoginRequired = ::startLogin,
)
webChromeClient =
OmnigentWebChromeClient(
onChooseFiles = ::chooseFiles,
onPermission = ::handlePermissionRequest,
)
setDownloadListener { downloadUrl, _, contentDisposition, mimeType, _ ->
downloadFile(downloadUrl, contentDisposition, mimeType)
}
}
setContentView(webView)
installBridge()
// Measure the OS safe area and push it into the page as CSS custom
// properties — Android WebView can't rely on `env(safe-area-inset-*)`
// alone (unreliable < API 30 and across OEM builds). Cached so the first
// post-load emit (in onPageReady) isn't lost to the pre-load race.
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
// Edge-to-edge (setDecorFitsSystemWindows=false, above) neutralizes the
// manifest's adjustResize: the window no longer shrinks when the IME
// opens, so bottom-anchored web content (a chat composer, a terminal
// input) would sit BEHIND the keyboard. Resize the WebView ourselves —
// shrink its laid-out HEIGHT by the IME inset. It must be the view
// height (a bottom margin), not bottom padding: the CSS viewport
// (100vh / the visual viewport that fixed/sticky content anchors to)
// tracks the WebView's height, not its content box, so padding alone
// wouldn't reflow the composer above the keyboard. This is the
// adjustResize equivalent for an edge-to-edge window; the status/nav
// bars stay CSS safe-areas so content still draws behind them.
// Type.ime() is the real platform inset on API 30+; on 28-29 androidx
// backfills it from adjustResize's systemWindowInsets, so the resize
// still fires. If some pre-30 OEM reports none, the margin stays 0 and
// we simply degrade to the old (unresized) behavior — no regression.
(view.layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp ->
if (lp.bottomMargin != ime.bottom) {
lp.bottomMargin = ime.bottom
view.layoutParams = lp
}
}
// Bottom safe-area: the nav bar when the keyboard is hidden; 0 while
// it's up (the resize already lifts content above the keyboard, and the
// keyboard covers the nav bar). Top/left/right are IME-independent.
val bottom = if (ime.bottom > 0) 0 else bars.bottom
lastInsets = Insets.of(bars.left, bars.top, bars.right, bottom)
emitInsets()
insets
}
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Ask the page to dismiss an open overlay first (drawer/dialog);
// only navigate WebView history / leave the app if there was
// nothing to dismiss. evaluateJavascript's result is JSON, so a
// true return arrives as the string "true".
//
// This callback always consumes Back, but the JS round-trip is
// async and its callback is silently dropped if the renderer is
// gone (OOM-killed / hung) — which would strand the user with a
// dead Back. So race a timeout fallback: whichever of the JS
// result or the timer fires first navigates, the other no-ops.
// Both run on the main thread, so the plain flag needs no lock.
var acted = false
// If the host is already going away when a late callback/timer
// fires, don't touch the (possibly destroyed) WebView.
val navigate = {
if (!isDestroyed && !isFinishing && ::webView.isInitialized) {
if (webView.canGoBack()) webView.goBack() else finish()
}
}
val fallback =
Runnable {
if (!acted) {
acted = true
navigate()
}
}
webView.postDelayed(fallback, BACK_FALLBACK_MS)
webView.evaluateJavascript(
"!!(window.__omnigentNativeHandleBack && window.__omnigentNativeHandleBack())",
) { handled ->
if (!acted) {
acted = true
webView.removeCallbacks(fallback)
if (handled != "true") navigate()
}
}
}
},
)
ensureNotificationPermission()
webView.loadUrl(serverUrl)
}
/**
* Install the web -> native bridge as an origin-allowlisted web message
* listener (NOT addJavascriptInterface): the transport object reaches only
* frames on the pinned origin, and [OmnigentBridgeListener] also drops
* non-main-frame messages — so a sandboxed agent-HTML iframe can't reach it.
* Requires WebView 88+ (the same floor as our env()/inset handling); if the
* feature is missing the bridge is simply absent and the web layer falls back.
*/
private fun installBridge() {
val origin = pinnedOrigin ?: return
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
try {
WebViewCompat.addWebMessageListener(
webView,
OmnigentBridgeListener.JS_OBJECT_NAME,
setOf(origin),
OmnigentBridgeListener(notifications, blobSaver),
)
} catch (_: IllegalArgumentException) {
// Malformed origin rule — leave the bridge absent; the web layer falls back.
}
}
/**
* Run the RFC 8252 login flow: authenticate in the system browser
* (Google/passkey work there, not in a WebView), then [onSessionToken]
* injects the session. Triggered by [OmnigentWebViewClient] when the server
* redirects to the IdP.
*
* Capped retries: if injecting the session still leaves us redirected to
* login (rejected cookie, expired token, clock skew), don't relaunch the
* browser forever — give up after [MAX_LOGIN_ATTEMPTS]. The counter resets in
* onPageReady once a pinned-origin page actually loads (i.e. we're past the
* login redirect).
*/
private fun startLogin() {
val origin = pinnedOrigin ?: return
if (loginAttempts >= MAX_LOGIN_ATTEMPTS) {
authLog("login attempts exhausted ($loginAttempts) — not retrying")
return
}
// start() no-ops when a login is already in flight — a multi-hop OIDC
// redirect can re-enter this before the first browser hand-off settles.
// Count (and re-arm the history clear for) only a call that actually
// launches a flow, so re-entrant redirects can't burn the retry budget
// without ever relaunching and suppress a legitimate later retry.
if (!loginManager.start(this, origin, ::onSessionToken)) return
loginAttempts++
// A re-login (session expired mid-use) bounces through the IdP again,
// leaving a stopped off-origin entry + stale pre-expiry pages on the back
// stack. Re-arm the one-shot history clear so the next authenticated
// page-ready purges them — otherwise Back walks into the stopped IdP entry
// and re-pops the login browser.
historyCleared = false
}
/**
* Bridge the session from the browser into the WebView: the polled JWT is
* exactly the session-cookie value, so set it as the cookie (the browser's
* cookie store is isolated from the WebView's), reload authenticated, and get
* the user back to the app.
*
* Foregrounding ourselves from the background (the poll completes while the
* browser is in front) is blocked by Android's background-activity-launch
* rules, so we both attempt a reorder-to-front (works within the grace
* period) AND post a "tap to return" notification as the reliable path back.
*/
private fun onSessionToken(token: String) {
// The poll can land after the activity is gone (it ran on a background
// thread up to 5 min) — never touch a destroyed WebView.
if (isDestroyed || isFinishing || !::webView.isInitialized) return
// Defense-in-depth: the token is interpolated into the cookie string, so a
// value carrying ';' or whitespace could smuggle in cookie attributes
// (e.g. Domain=, defeating the __Host- prefix). A real session token is an
// HS256 JWT — three base64url segments — which never contains those, so
// this only ever rejects a malformed/hostile value, never a valid login.
if (!isJwtShaped(token)) {
authLog("onSessionToken: token not JWT-shaped — rejecting")
return
}
val origin = pinnedOrigin ?: return
val secure = origin.startsWith("https://")
// Matches the server's session_cookie_name: __Host- prefix on HTTPS.
val name = if (secure) "__Host-ap_session" else "ap_session"
val cookie =
buildString {
append(name).append('=').append(token).append("; Path=/")
if (secure) append("; Secure")
append("; SameSite=Lax")
}
val cookies = CookieManager.getInstance()
cookies.setAcceptCookie(true)
authLog("onSessionToken: injecting $name (token len=${token.length})")
cookies.setCookie(origin, cookie) { accepted ->
// setCookie's callback is async — re-check the WebView is still alive.
if (isDestroyed || !::webView.isInitialized) return@setCookie
authLog(
"setCookie accepted=$accepted present=${cookies
.getCookie(
origin,
)?.contains(name) == true}",
)
// A rejected cookie means the reload would land unauthenticated,
// bounce to login, and re-launch the browser — burning the retry
// budget on a failure that retrying can't fix. Stay put instead.
if (!accepted) return@setCookie
cookies.flush()
webView.loadUrl(origin)
}
startActivity(
Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
notifications.notify(
title = getString(R.string.signed_in_title),
body = getString(R.string.signed_in_body),
navigatePath = "/",
)
}
/**
* True if [token] is shaped like a JWT — three non-empty base64url segments
* (`header.payload.signature`). base64url is `[A-Za-z0-9_-]`, so a JWT can
* never carry the `;`, whitespace, or control chars that would let a value
* break out of the cookie string and inject attributes.
*/
private fun isJwtShaped(token: String): Boolean {
val parts = token.split('.')
if (parts.size != 3) return false
return parts.all { part ->
part.isNotEmpty() &&
part.all { c ->
c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c == '-' || c == '_'
}
}
}
override fun onDestroy() {
// Unblock a pending file input / mic request, then release WebView + worker.
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null
pendingMicRequest?.deny()
pendingMicRequest = null
loginManager.shutdown()
if (::blobSaver.isInitialized) blobSaver.shutdown()
if (::webView.isInitialized) webView.destroy() // releases the bridge chain
super.onDestroy()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val path = navigatePathOf(intent) ?: return
pendingNavigatePath = path
// Replay now if the page is up; otherwise onPageReady will flush it.
if (pageLoaded) flushPendingActivation()
}
/** Run bridge-dependent work once a pinned-origin page has finished loading. */
private fun onPageReady(url: String?) {
// Only a real pinned-origin load carries the injected facade — an error
// page (chrome-error://) or a foreign redirect must NOT drain
// pendingNavigatePath or push insets into a page that can't consume them.
if (originOf(url) != pinnedOrigin) return
// First authenticated app page: drop everything before it from the
// back/forward list. Otherwise Back walks into the pre-auth root and the
// login-redirect reload (the `loadUrl(origin)` after the cookie injection),
// which bounces to login or shows a blank — "back lands on the wrong
// screen" / "exits the app". After this the SPA builds clean history.
if (!historyCleared) {
historyCleared = true
webView.clearHistory()
}
pageLoaded = true
loginAttempts = 0 // reached a pinned-origin page — we're past the login redirect
flushPendingActivation()
emitInsets()
}
private fun flushPendingActivation() {
// A tap can arrive (onNewIntent) while the WebView is parked off-origin —
// e.g. mid re-login — where the bridge facade doesn't exist, so emitting
// would silently drop the path. Keep it pending; the next pinned-origin
// onPageReady flushes it.
if (originOf(webView.url) != pinnedOrigin) return
emitNotificationActivation(pendingNavigatePath)
pendingNavigatePath = null
}
private fun navigatePathOf(intent: Intent?): String? =
intent
?.getStringExtra(NativeNotificationManager.EXTRA_NAVIGATE_PATH)
?.takeIf { it.startsWith("/") }
private fun emitNotificationActivation(path: String?) {
if (path == null) return
webView.evaluateJavascript(
"window.__omnigentNativeEmitNotificationActivated && " +
"window.__omnigentNativeEmitNotificationActivated(${jsString(path)});",
null,
)
}
private fun emitInsets() {
// Feed the OS safe area into the web layer two ways, because the shell
// pins to a user-supplied server whose web build may PRE-DATE the Android
// shell's CSS — it can't be assumed to carry the `[data-android-native]`
// fold:
// 1. `--omnigent-safe-top/bottom` — the app's OWN base inset vars. Every
// build already derives `--omnigent-inset-*` and its layout from
// these, defaulting them to `env(safe-area-inset-*)`, which Android
// WebView reports as 0. Setting them inline (highest priority)
// overrides that 0 everywhere the layout already reads them.
// 2. `--omnigent-android-safe-area-*` — consumed by the shell's own
// `[data-android-native]` rules when the server IS up to date (folded
// via max() in index.css); a harmless no-op otherwise.
// We deliberately do NOT call `__omnigentNativeEmitInsets` — that feeds the
// iOS *floating-bar* footprints (--omnigent-native-*-bar; nativeInsets.ts
// is a "no-op off the iOS shell"), and Android has no such bars. Routing
// the safe area there would mis-assign it to a bar-footprint variable.
val bars = lastInsets ?: return
val d = resources.displayMetrics.density
val js =
"""
(() => {
const s = document.documentElement.style;
const top = '${bars.top / d}px';
const bottom = '${bars.bottom / d}px';
s.setProperty('--omnigent-safe-top', top);
s.setProperty('--omnigent-safe-bottom', bottom);
s.setProperty('--omnigent-android-safe-area-top', top);
s.setProperty('--omnigent-android-safe-area-bottom', bottom);
s.setProperty('--omnigent-android-safe-area-left', '${bars.left / d}px');
s.setProperty('--omnigent-android-safe-area-right', '${bars.right / d}px');
})();
""".trimIndent()
webView.evaluateJavascript(js, null)
}
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
private fun ensureNotificationPermission() {
// Notification permission is granted at install time below API 33.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (!hasPermission(Manifest.permission.POST_NOTIFICATIONS)) {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
/** Back [OmnigentWebChromeClient.onShowFileChooser] with a document picker. */
private fun chooseFiles(
callback: ValueCallback<Array<Uri>>,
acceptTypes: Array<String>,
): Boolean {
pendingFileCallback?.onReceiveValue(null) // cancel any in-flight chooser
pendingFileCallback = callback
// Keep MIME types as-is; resolve ".pdf"-style extension tokens to MIME so
// the declared accept constraint isn't silently widened to */*.
val mimeTypes =
acceptTypes
.mapNotNull(
::mimeTypeFor,
).toTypedArray()
.ifEmpty { arrayOf("*/*") }
return try {
pickFiles.launch(mimeTypes)
true
} catch (_: Throwable) {
pendingFileCallback = null
callback.onReceiveValue(null) // resolve the <input> rather than hang it
true
}
}
/** A web accept token (a MIME type, or a ".pdf"-style extension) -> a MIME type, or null. */
private fun mimeTypeFor(accept: String): String? {
val token = accept.trim()
return when {
token.isEmpty() -> {
null
}
token.contains('/') -> {
token
}
// already a MIME type / wildcard
else -> {
MimeTypeMap
.getSingleton()
.getMimeTypeFromExtension(token.removePrefix(".").lowercase())
}
}
}
/** Back [OmnigentWebChromeClient.onPermissionRequest] — grant mic to the pinned origin only. */
private fun handlePermissionRequest(request: PermissionRequest) {
val wantsAudio = request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)
if (!wantsAudio || originOf(request.origin?.toString()) != pinnedOrigin) {
request.deny()
return
}
if (hasPermission(Manifest.permission.RECORD_AUDIO)) {
request.grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))
} else {
pendingMicRequest?.deny() // don't leave a prior request hanging forever
pendingMicRequest = request
requestMic.launch(Manifest.permission.RECORD_AUDIO)
}
}
private fun downloadFile(
url: String,
contentDisposition: String?,
mimeType: String?,
) {
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
// Agent-generated files arrive as blob:/data: URLs, which DownloadManager
// can't handle — fetch them in page context and save via the blob bridge
// (fixes omnigent-ai/omnigent#969, which the iOS shell leaves broken).
if (url.startsWith("blob:") || url.startsWith("data:")) {
webView.evaluateJavascript(BlobDownloadScript.fetchAsBase64(url, name), null)
return
}
// Same normalization as the navigation gate: accepts odd casing
// ("HTTPS://") and rejects non-http lookalikes ("httpfoo:"), which
// DownloadManager.Request would otherwise throw on.
if (!isHttpScheme(Uri.parse(url).scheme)) return
val request =
DownloadManager.Request(Uri.parse(url)).apply {
setMimeType(mimeType)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED,
)
}
getSystemService<DownloadManager>()?.enqueue(request)
}
private companion object {
const val MAX_LOGIN_ATTEMPTS = 3
// Back-press fallback: long enough that a healthy renderer's JS round-trip
// (a few ms) always wins the race, short enough to not feel stuck if it
// doesn't answer. Only the timer ever fires when the renderer is gone.
const val BACK_FALLBACK_MS = 600L
}
}
@@ -0,0 +1,243 @@
package ai.omnigent.android
/**
* The JavaScript injected into the main frame on every load to expose
* `window.omnigentNative` with `kind: "android"`, mirroring the iOS shell's
* bridge (`web/ios/Omnigent/OmnigentWebView.swift`). The web layer consumes
* this through `web/src/lib/nativeBridge.ts` — same object shape, same
* `__omnigentNativeEmit*` callback names — so no web change is needed beyond
* accepting the `"android"` discriminator.
*
* web -> native goes through [OmnigentBridgeListener.JS_OBJECT_NAME], the
* transport object injected by `WebViewCompat.addWebMessageListener` only into
* frames on the pinned origin. `notify()` resolves `true` optimistically (as on
* iOS) since the post is fire-and-forget. native -> web is driven by
* `evaluateJavascript` into the `window.__omnigentNativeEmit*` functions here.
*/
object NativeBridgeScript {
val source: String =
"""
(() => {
if (window.omnigentNative && window.omnigentNative.kind === "android") return;
const ensureViewportFit = () => {
let meta = document.querySelector('meta[name="viewport"]');
if (!meta) {
meta = document.createElement("meta");
meta.name = "viewport";
(document.head || document.documentElement).appendChild(meta);
}
const content = meta.getAttribute("content") || "width=device-width, initial-scale=1.0";
const managedKeys = new Set([
"width", "initial-scale", "minimum-scale",
"maximum-scale", "user-scalable", "viewport-fit",
]);
const preserved = content
.split(",").map((p) => p.trim())
.filter((p) => {
const key = p.split("=")[0]?.trim().toLowerCase();
return key && !managedKeys.has(key);
});
meta.setAttribute("content", [
"width=device-width", "initial-scale=1.0", "minimum-scale=1.0",
"maximum-scale=1.0", "user-scalable=no", "viewport-fit=cover",
...preserved,
].join(", "));
};
if (document.head) ensureViewportFit();
else document.addEventListener("DOMContentLoaded", ensureViewportFit, { once: true });
// Apply the OS safe area to the layout from the native side. emitInsets
// feeds --omnigent-safe-top/bottom (the app's own inset vars), but on a
// server whose web build predates the Android shell the inset-aware rules
// lose the cascade: their semantic selectors (.chat-conversation-content
// etc., specificity 0,1,0) tie with the Tailwind utility classes on the
// same elements and lose on source order, so the OS inset is dropped and
// content bleeds under the status bar / behind the gesture nav. Re-assert
// the inset paddings here with !important so they win regardless, keyed to
// the same vars (mirrors the [data-android-native] rules in index.css).
// The header additionally reads a raw env(safe-area-inset-top), which
// Android WebView reports as 0, so it needs the override even pre-Tailwind.
const ensureInsetStyles = () => {
if (document.getElementById("omnigent-android-insets")) return;
const T = "var(--omnigent-safe-top, 0px)";
const B = "var(--omnigent-safe-bottom, 0px)";
const style = document.createElement("style");
style.id = "omnigent-android-insets";
style.textContent = [
".chat-header{top:max(0px, calc(" + T + " - 0.5rem)) !important}",
".chat-conversation-content{padding-top:calc(var(--omnigent-header-height, 3.5rem) + 1.5rem + " + T + ") !important}",
".main-terminal-view{padding-top:calc(3.25rem + " + T + ") !important}",
// Bottom inset belongs on whichever element is bottom-most per mode:
// the composer in regular chat, the switcher pill in terminal-first
// (its composer sits above the pill, so it must NOT also add it).
".chat-composer-form{padding-bottom:calc(0.75rem + " + B + ") !important}",
".chat-composer-form.terminal-first-composer-form{padding-bottom:0.25rem !important}",
".terminal-first-switcher-container{padding-bottom:calc(0.35rem + " + B + ") !important}",
// Drawers/panels span full height — clear both bars.
":is(.conversations-sidebar,[data-testid=\"file-viewer\"],[data-testid=\"files-panel-drawer\"],[data-testid=\"terminals-panel\"],[data-testid=\"subagents-panel-drawer\"],[data-testid=\"todos-panel-drawer\"]){padding-top:" + T + " !important;padding-bottom:" + B + " !important}",
].join("");
(document.head || document.documentElement).appendChild(style);
};
if (document.head) ensureInsetStyles();
else document.addEventListener("DOMContentLoaded", ensureInsetStyles, { once: true });
const post = (payload) => {
try {
const bridge = window.${OmnigentBridgeListener.JS_OBJECT_NAME};
if (bridge) bridge.postMessage(JSON.stringify(payload));
} catch (_) {}
};
const notificationCallbacks = new Set();
// An activation is a fire-once event, but the native side may emit it
// (cold-start tap, replayed at page-ready) BEFORE the React listener
// mounts. So if there is no subscriber yet, stash the path and hand it
// to the FIRST subscriber once, then clear it — never re-deliver.
let pendingNotificationPath = null;
Object.defineProperty(window, "__omnigentNativeEmitNotificationActivated", {
configurable: false, enumerable: false, writable: false,
value(path) {
if (typeof path !== "string" || !path.startsWith("/")) return;
if (notificationCallbacks.size === 0) { pendingNotificationPath = path; return; }
for (const cb of notificationCallbacks) { try { cb(path); } catch (_) {} }
},
});
const insetCallbacks = new Set();
// Cache the last footprint so a subscriber that registers AFTER native
// first emitted (the React app mounts later than document-start) still
// gets the current value immediately on subscribe.
let lastInsets = null;
Object.defineProperty(window, "__omnigentNativeEmitInsets", {
configurable: false, enumerable: false, writable: false,
value(topBar, bottomBar) {
const insets = {
topBar: typeof topBar === "number" && Number.isFinite(topBar) ? topBar : 0,
bottomBar: typeof bottomBar === "number" && Number.isFinite(bottomBar) ? bottomBar : 0,
};
lastInsets = insets;
for (const cb of insetCallbacks) { try { cb(insets); } catch (_) {} }
},
});
// Android hardware/gesture back: close an open in-page overlay first so
// back means "dismiss this drawer/dialog", not "leave the app" (the SPA
// doesn't put every overlay in history). Returns true only when it
// actually closed a VISIBLE modal/drawer — the native side falls back to
// WebView history / finish() otherwise, so this never traps the user.
Object.defineProperty(window, "__omnigentNativeHandleBack", {
configurable: false, enumerable: false, writable: false,
value() {
try {
// An overlay counts as open only if its CENTER is on-screen. The
// panel drawers stay in the DOM at full size when CLOSED — just
// translated off the side (e.g. left:524 on a 523px screen) — so a
// size + vertical test alone false-detects a closed drawer and
// swallows Back ("does nothing"). offsetParent is also null for the
// position:fixed overlays, so it can't be used either.
const onScreen = (el) => {
if (!el) return false;
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) === 0) return false;
const r = el.getBoundingClientRect();
if (r.width < 24 || r.height < 24) return false;
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
return cx > 0 && cx < window.innerWidth && cy > 0 && cy < window.innerHeight;
};
// One dispatch on document — Radix/most libs listen there and close
// only the TOP layer per Escape. Dispatching twice (element-bubbled
// + direct) could collapse two stacked overlays in a single Back.
const fireEscape = () => {
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "Escape", code: "Escape", keyCode: 27, which: 27,
bubbles: true, cancelable: true,
}));
};
// Below Tailwind's md breakpoint the side surfaces are DRAWERS
// (overlays) Back should dismiss; at md+ they dock as persistent
// rails (md:relative md:translate-x-0, still data-state="open")
// that Back must NOT close. Modal dialogs dismiss at any width.
const narrow = window.innerWidth < 768;
// 1. Conversations sidebar (a drawer only when narrow; closed =
// data-collapsed).
if (narrow) {
const sb = document.querySelector(".conversations-sidebar");
if (sb && sb.getAttribute("data-collapsed") !== "true" && onScreen(sb)) {
const toggle = [...document.querySelectorAll("button")].find(
(b) => /close sidebar/i.test(b.getAttribute("aria-label") || ""),
);
if (toggle) toggle.click(); else fireEscape();
return true;
}
}
// 2. Any open Radix-style overlay. data-state="open" is the
// authoritative open/closed signal; a closed one is "closed".
let target = null;
for (const el of document.querySelectorAll('[data-state="open"]')) {
const testid = el.getAttribute("data-testid") || "";
const isModal =
el.getAttribute("role") === "dialog" ||
el.getAttribute("aria-modal") === "true";
const isDrawerPanel = /-(drawer|panel|sheet)${'$'}/.test(testid);
// Modal dismisses at any width; a drawer/rail panel only while
// it's actually a drawer (narrow), not a docked md+ rail.
if ((isModal || (isDrawerPanel && narrow)) && onScreen(el)) { target = el; break; }
}
// File viewer / terminals panel may not carry data-state — drawers
// only when narrow (they're persistent rails at md+).
if (!target && narrow) {
for (const el of document.querySelectorAll(
'[data-testid="file-viewer"], [data-testid="terminals-panel"]',
)) { if (onScreen(el)) { target = el; break; } }
}
if (!target) return false;
const closer = target.querySelector(
'[aria-label*="lose" i], [data-testid${'$'}="-close"], [data-testid*="close" i], button[data-dismiss]',
);
if (closer) closer.click(); else fireEscape();
return true;
} catch (_) { return false; }
},
});
window.omnigentNative = Object.freeze({
kind: "android",
setBadgeCount(count) {
// Note: unlike iOS, the native side ignores count <= 0 — Android has
// no badge-clear API, so a previously-set badge can't be cleared
// from the web (see NativeNotificationManager.setBadgeCount).
post({ method: "setBadgeCount", count: Number.isFinite(count) ? count : 0 });
},
notify(params) {
post({
method: "notify",
params: {
title: params && typeof params.title === "string" ? params.title : "",
body: params && typeof params.body === "string" ? params.body : "",
navigatePath:
params && typeof params.navigatePath === "string" ? params.navigatePath : "",
},
});
return Promise.resolve(true);
},
onNotificationActivated(callback) {
if (typeof callback !== "function") return () => {};
notificationCallbacks.add(callback);
if (pendingNotificationPath) {
const p = pendingNotificationPath;
pendingNotificationPath = null;
try { callback(p); } catch (_) {}
}
return () => notificationCallbacks.delete(callback);
},
onNativeInsets(callback) {
if (typeof callback !== "function") return () => {};
insetCallbacks.add(callback);
if (lastInsets) { try { callback(lastInsets); } catch (_) {} }
return () => insetCallbacks.delete(callback);
},
});
})();
""".trimIndent()
}
@@ -0,0 +1,130 @@
package ai.omnigent.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import java.util.concurrent.atomic.AtomicInteger
/**
* Local (foreground) notifications + best-effort badge, mirroring the iOS
* `NativeNotificationManager`. Tap routing forwards the notification's
* `navigatePath` back into the SPA: the tap launches [MainActivity] with the
* path as an intent extra, which the activity replays via
* `window.__omnigentNativeEmitNotificationActivated`.
*
* Posting tolerates a missing `POST_NOTIFICATIONS` grant (requested by
* [MainActivity] on API 33+): [post] drops silently if disabled or revoked, so
* the web layer keeps working without OS toasts.
*/
class NativeNotificationManager(
private val context: Context,
) {
private val manager = NotificationManagerCompat.from(context)
// Ids at/above BADGE_NOTIFICATION_ID + 1 so per-session toasts never collide
// with the reserved badge-summary notification.
private val nextId = AtomicInteger(BADGE_NOTIFICATION_ID + 1)
init {
val channel =
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_HIGH,
)
manager.createNotificationChannel(channel)
}
fun notify(
title: String,
body: String?,
navigatePath: String?,
) {
val id = nextId.getAndIncrement()
val builder =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body.orEmpty())
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
if (navigatePath != null && navigatePath.startsWith("/")) {
builder.setContentIntent(activationIntent(navigatePath, id))
}
post(id, builder.build())
}
/**
* Android has no universal numeric icon badge. We attach the count to a
* lightweight summary notification via `setNumber()` (surfaced by some
* launchers; AOSP shows only a dot). A count of 0 is a no-op: we never
* cancel notifications just to clear a badge.
*/
fun setBadgeCount(count: Int) {
if (count <= 0) return
val summary =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.app_name))
.setContentText(
context.resources.getQuantityString(R.plurals.badge_text, count, count),
).setNumber(count)
.setSilent(true)
.setOngoing(false)
.build()
post(BADGE_NOTIFICATION_ID, summary)
}
/**
* Post a notification, tolerating a missing notification grant. The
* `POST_NOTIFICATIONS` permission is revocable on API 33+, so `notify` can
* throw `SecurityException` even after `areNotificationsEnabled()` — we drop
* silently rather than crash.
*/
private fun post(
id: Int,
notification: Notification,
) {
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(id, notification)
} catch (_: SecurityException) {
// POST_NOTIFICATIONS not granted — drop; web falls back.
}
}
// requestCode is the notification's own id, so each notification gets a
// distinct PendingIntent — otherwise FLAG_UPDATE_CURRENT would let two paths
// with colliding hashes overwrite each other's extras and mis-route a tap.
private fun activationIntent(
navigatePath: String,
requestCode: Int,
): PendingIntent {
val intent =
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_NAVIGATE_PATH, navigatePath)
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
companion object {
const val EXTRA_NAVIGATE_PATH = "ai.omnigent.android.NAVIGATE_PATH"
private const val CHANNEL_ID = "omnigent.sessions"
private const val BADGE_NOTIFICATION_ID = 1
}
}
@@ -0,0 +1,183 @@
package ai.omnigent.android
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* Drives the RFC 8252 login flow for the shell: authenticate in the system
* browser — a real browser, so Google sign-in (which blocks embedded WebViews
* with `disallowed_useragent`) and passkeys (which need the browser / a password
* manager) both work — then bridge the resulting session into the WebView, whose
* cookie store is isolated from the browser's.
*
* Reuses the server's existing browser-login endpoints (the same ones the
* `omnigent login` CLI uses, no server change):
* 1. `POST /auth/cli-login` -> `{ticket, login_url}`
* 2. open `login_url` in the browser; the user authenticates; the OIDC
* callback fulfills the ticket server-side
* 3. `GET /auth/cli-poll?ticket=...` -> `{token}` once fulfilled
*
* That `token` is exactly the session-cookie JWT (the server validates the same
* HS256 JWT as either the session cookie or a `Bearer`), so [MainActivity]
* injects it into the WebView's CookieManager and reloads — authenticated.
*/
class OidcLoginManager {
private val io = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())
private val inFlight = AtomicBoolean(false)
// Held only for the duration of a login; nulled by [shutdown] so a poll that
// finishes after the host is destroyed can neither invoke into a dead
// Activity nor pin it (and its View tree) for the poll's lifetime.
@Volatile private var sessionCallback: ((String) -> Unit)? = null
/**
* Begin a login against [origin] (the pinned server). Opens the browser and
* polls in the background; [onSession] is invoked on the main thread with the
* session JWT once the browser flow completes.
*
* Returns true if this call started a flow, or false if one was already in
* flight (a second concurrent call is ignored). The caller uses the result so
* a no-op call isn't counted against a retry budget.
*/
fun start(
activity: Activity,
origin: String,
onSession: (String) -> Unit,
): Boolean {
if (!inFlight.compareAndSet(false, true)) return false
sessionCallback = onSession
io.execute {
var token: String? = null
try {
val ticket = requestTicket(origin)
authLog("cli-login -> ${if (ticket != null) "ticket ok" else "FAILED"}")
if (ticket != null) {
main.post { launchTab(activity, origin + ticket.loginUrl) }
token = pollForToken(origin, ticket.id)
authLog(
"poll -> ${if (token != null) "token (len=${token.length})" else "no token"}",
)
}
} catch (_: InterruptedException) {
// shutdown() interrupted the poll — the host is going away; drop.
} catch (t: Throwable) {
authLog("login flow error: ${t.javaClass.simpleName}")
} finally {
inFlight.set(false)
}
val result = token
// sessionCallback is null once shutdown() ran — never invoke into a
// destroyed host.
if (result != null) main.post { sessionCallback?.invoke(result) }
}
return true
}
/** Cancel an in-flight login and release the host. Call from onDestroy. */
fun shutdown() {
sessionCallback = null
io.shutdownNow() // interrupts the polling sleep so the task exits promptly
}
private data class Ticket(
val id: String,
val loginUrl: String,
)
private fun requestTicket(origin: String): Ticket? {
val conn = (URL("$origin/auth/cli-login").openConnection() as HttpURLConnection)
conn.requestMethod = "POST"
// Bodyless POST — set Content-Length explicitly; some servers/WAFs reject
// a POST without it (411 Length Required).
conn.setRequestProperty("Content-Length", "0")
conn.connectTimeout = HTTP_TIMEOUT_MS
conn.readTimeout = HTTP_TIMEOUT_MS
return try {
if (conn.responseCode != 200) return null
val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
val id = json.optString("ticket").ifEmpty { return null }
val loginUrl = json.optString("login_url").ifEmpty { return null }
// The browser hand-off must stay on the pinned origin: [start]
// concatenates this onto it, so only a relative path may pass — an
// absolute URL or a scheme-relative `//host` would send the one-time
// ticket flow to a server-chosen destination instead.
if (!loginUrl.startsWith("/") || loginUrl.startsWith("//")) return null
Ticket(id, loginUrl)
} finally {
conn.disconnect()
}
}
private fun launchTab(
activity: Activity,
url: String,
) {
// Full system browser (not a Custom Tab): the IdP flow page renders blank
// in an in-app Custom Tab on some setups but works in the browser. Still
// RFC 8252 — the system browser is the canonical external user-agent.
authLog("opening login in browser") // URL carries the one-time ticket — not logged
val intent =
Intent(
Intent.ACTION_VIEW,
Uri.parse(url),
).addCategory(Intent.CATEGORY_BROWSABLE)
runCatching { activity.startActivity(intent) }
}
private fun pollForToken(
origin: String,
ticket: String,
): String? {
val deadline = System.currentTimeMillis() + POLL_TIMEOUT_MS
val encoded = Uri.encode(ticket)
while (System.currentTimeMillis() < deadline) {
Thread.sleep(POLL_INTERVAL_MS) // throws InterruptedException on shutdownNow()
val conn = (
URL(
"$origin/auth/cli-poll?ticket=$encoded",
).openConnection() as HttpURLConnection
)
conn.requestMethod = "GET"
conn.connectTimeout = HTTP_TIMEOUT_MS
conn.readTimeout = HTTP_TIMEOUT_MS
try {
when (conn.responseCode) {
202 -> {
continue
}
// still pending
200 -> {
val body = conn.inputStream.bufferedReader().use { it.readText() }
return JSONObject(body).optString("token").ifEmpty { null }
}
else -> {
return null
} // 410 expired/rejected, or other
}
} catch (_: Throwable) {
if (Thread.currentThread().isInterrupted) return null // shutdown mid-request
continue // transient network error — keep polling until the deadline
} finally {
conn.disconnect()
}
}
return null
}
private companion object {
const val POLL_INTERVAL_MS = 2_000L
const val POLL_TIMEOUT_MS = 5 * 60 * 1_000L // mirrors the CLI's 5-minute window
const val HTTP_TIMEOUT_MS = 10_000 // connect + read timeout for the login endpoints
}
}
@@ -0,0 +1,72 @@
package ai.omnigent.android
import android.net.Uri
import android.webkit.WebView
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import org.json.JSONObject
/**
* The single web -> native bridge, installed via
* `WebViewCompat.addWebMessageListener` with an origin allowlist of just the
* pinned server. Unlike `addJavascriptInterface`, the injected object
* (`window.`[JS_OBJECT_NAME]`)` is delivered ONLY to frames whose origin
* matches the allowlist, so a sandboxed / opaque agent-HTML iframe never
* receives it. We additionally drop non-main-frame messages — together the
* structural equivalent of the iOS `isMainFrame` + frame-origin check that a
* raw `addJavascriptInterface` bridge cannot express.
*
* Callbacks arrive on the UI thread, so notification calls need no hop; the
* blob write offloads to [BlobSaver]'s own worker.
*/
class OmnigentBridgeListener(
private val notifications: NativeNotificationManager,
private val blobSaver: BlobSaver,
) : WebViewCompat.WebMessageListener {
override fun onPostMessage(
view: WebView,
message: WebMessageCompat,
sourceOrigin: Uri,
isMainFrame: Boolean,
replyProxy: JavaScriptReplyProxy,
) {
if (!isMainFrame) return // origin allowlist already gates; defense in depth.
val data = message.data ?: return
val json =
try {
JSONObject(data)
} catch (_: Throwable) {
return
}
when (json.optString("method")) {
"setBadgeCount" -> {
notifications.setBadgeCount(json.optInt("count", 0))
}
"notify" -> {
val params = json.optJSONObject("params") ?: return
val title = params.optString("title").ifEmpty { return }
notifications.notify(
title = title,
body = params.optString("body").ifEmpty { null },
navigatePath = params.optString("navigatePath").ifEmpty { null },
)
}
"blobBase64" -> {
blobSaver.save(
base64 = json.optString("base64").ifEmpty { return },
mimeType = json.optString("mimeType").ifEmpty { "application/octet-stream" },
suggestedName = json.optString("name"),
)
}
}
}
companion object {
/** Name of the injected transport object as seen from page JS. */
const val JS_OBJECT_NAME = "omnigentNativeBridge"
}
}
@@ -0,0 +1,37 @@
package ai.omnigent.android
import android.net.Uri
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebView
/**
* Supplies the two browser affordances a bare `WebView` lacks but the SPA needs,
* delegating the parts that require Activity-scoped result launchers / runtime
* permissions back to [MainActivity]:
*
* - **File upload** (`<input type=file>`): without `onShowFileChooser` the
* picker never opens and the attach button silently does nothing.
* - **Microphone** (`getUserMedia({audio:true})`): without `onPermissionRequest`
* the WebView denies capture, so voice input is dead.
*/
class OmnigentWebChromeClient(
/** Open a file picker for [acceptTypes]; return true if it was launched. */
private val onChooseFiles: (
callback: ValueCallback<Array<Uri>>,
acceptTypes: Array<String>,
) -> Boolean,
/** Grant or deny a WebView permission request (currently audio capture). */
private val onPermission: (PermissionRequest) -> Unit,
) : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams,
): Boolean = onChooseFiles(filePathCallback, fileChooserParams.acceptTypes ?: emptyArray())
override fun onPermissionRequest(request: PermissionRequest) {
onPermission(request)
}
}
@@ -0,0 +1,108 @@
package ai.omnigent.android
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewFeature
/**
* Injects the [NativeBridgeScript] facade on the pinned origin, signals
* [onPageReady] once a pinned-origin page finishes loading, and routes the OIDC
* login flow to the system browser via [onLoginRequired].
*
* Unlike iOS's `WKUserScript(.atDocumentStart)`, Android has no pre-JS injection
* hook; `onPageStarted` fires after the first response byte — in practice before
* the SPA's bundle evaluates, so `window.omnigentNative` is present by the time
* React mounts. Anything depending on the injected emit-callbacks (notification
* replay, inset push) waits for [onPageReady].
*/
class OmnigentWebViewClient(
private val pinnedOrigin: () -> String?,
private val onPageReady: (url: String?) -> Unit,
private val onLoginRequired: () -> Unit,
) : WebViewClient() {
override fun onPageStarted(
view: WebView,
url: String?,
favicon: Bitmap?,
) {
super.onPageStarted(view, url, favicon)
val origin = originOf(url)
val scheme = url?.let { Uri.parse(it).scheme?.lowercase() }
// A real http(s) navigation to a foreign origin means the server bounced
// us to the OIDC IdP and shouldOverrideUrlLoading didn't catch the
// redirect. Stop and run native system-browser login (RFC 8252: never
// authenticate in an embedded WebView; Google blocks it and passkeys don't
// work). Idempotent: the login manager ignores a second start while one is
// in flight.
//
// A null / about:blank / chrome-error:// URL is a failed or transitional
// load of the pinned server (e.g. it's offline), NOT an IdP redirect —
// don't misread it as a bounce and pop the browser. Mirror the http(s)
// gate in shouldOverrideUrlLoading.
if (isHttpScheme(scheme) && origin != pinnedOrigin()) {
// Log origin only, never the full URL (carries OAuth state/PKCE).
authLog("off-origin landing $origin -> login")
view.stopLoading()
onLoginRequired()
return
}
// Inject the facade ONLY on the pinned origin and only when the web
// message listener is supported — otherwise the web would see a dead
// bridge and suppress its own Web Notifications / fallbacks.
if (origin == pinnedOrigin() &&
WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)
) {
view.evaluateJavascript(NativeBridgeScript.source, null)
}
}
override fun onPageFinished(
view: WebView,
url: String?,
) {
super.onPageFinished(view, url)
onPageReady(url)
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val url = request.url
val scheme = url.scheme?.lowercase()
// Subframes (cross-origin iframes: web previews, embeds) load inline.
if (!request.isForMainFrame) return false
// Non-http(s) schemes (mailto:, tel:, intent:, custom links) can't load in
// the WebView — hand to the system, fail-closed if nothing handles them.
if (!isHttpScheme(scheme)) {
runCatching { view.context.startActivity(Intent(Intent.ACTION_VIEW, url)) }
return true
}
// Same-origin app pages load in the WebView.
val origin = originOf(url.toString())
if (origin == pinnedOrigin()) return false
// Off-origin top-level navigation. A server redirect (no user gesture) is
// the OIDC flow bouncing to the IdP -> run native system-browser login. A
// user gesture is an external link -> hand to the system browser. Either
// way the foreign page never loads in this WebView (which holds the
// native bridge).
authLog("off-origin nav $origin gesture=${request.hasGesture()}")
if (request.hasGesture()) {
runCatching { view.context.startActivity(Intent(Intent.ACTION_VIEW, url)) }
} else {
onLoginRequired()
}
return true
}
}
@@ -0,0 +1,55 @@
package ai.omnigent.android
import android.net.Uri
/**
* Normalizes a URL to its origin (`scheme://host[:port]`), the unit of trust
* the bridge and navigation gating compare on. Returns null for anything
* without both a scheme and host.
*/
fun originOf(url: String?): String? {
val uri = url?.let(Uri::parse) ?: return null
val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host?.lowercase() ?: return null
// Canonicalize like a browser origin (WHATWG): lowercase scheme + host and
// omit the default port — so an explicit `https://host:443` (or odd casing)
// the user typed compares equal to the WebView's normalized `https://host`.
// The pinned origin and every page URL both flow through here, so they
// canonicalize identically.
val port = uri.port
val hasExplicitPort =
port != -1 &&
!(scheme == "https" && port == 443) &&
!(scheme == "http" && port == 80)
return if (hasExplicitPort) "$scheme://$host:$port" else "$scheme://$host"
}
/**
* True for the only two schemes the WebView loads inline (http/https). This
* gates a security boundary (which navigations load in the bridged WebView vs.
* trigger login / hand off to the system), so it lowercases internally rather
* than trust callers to pre-normalize — `"HTTPS"` counts. Everything else
* (mailto:, intent:, about:, chrome-error://, null) is handed off or ignored.
*/
fun isHttpScheme(scheme: String?): Boolean {
val normalized = scheme?.lowercase() ?: return false
return normalized == "http" || normalized == "https"
}
/**
* Normalize user-entered server text into a loadable URL, or null if it isn't a
* usable http(s) address. Adds a default `https://` scheme when omitted and
* trims a trailing slash.
*/
fun normalizeServerUrl(input: String): String? {
val trimmed = input.trim().ifBlank { return null }
// No internal whitespace — a stray newline would otherwise split the
// newline-delimited recents store into bogus entries.
if (trimmed.any { it.isWhitespace() }) return null
val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed"
val uri = Uri.parse(withScheme)
val scheme = uri.scheme?.lowercase() ?: return null
if (!isHttpScheme(scheme)) return null
if (uri.host.isNullOrBlank()) return null
return withScheme.trimEnd('/')
}
@@ -0,0 +1,47 @@
package ai.omnigent.android
import android.content.Context
/**
* Persistence for the connected server URL plus a recent-servers list, backing
* [ConnectActivity]. Mirrors the iOS shell's `ConnectView` model (entry +
* recents); the native UI here is intentionally minimal.
*/
class ServerStore(
context: Context,
) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun hasServer(): Boolean = !prefs.getString(KEY_CURRENT, null).isNullOrBlank()
/** The current server, or the emulator-loopback debug default if unset. */
fun currentServerUrl(): String = prefs.getString(KEY_CURRENT, null) ?: DEFAULT_DEBUG_SERVER
/** Recently-connected servers, most recent first. */
fun recentServers(): List<String> =
prefs
.getString(KEY_RECENTS, null)
?.split("\n")
?.filter { it.isNotBlank() }
.orEmpty()
/** Set the current server and push it to the front of the recents list. */
fun connect(url: String) {
val recents = (listOf(url) + recentServers()).distinct().take(MAX_RECENTS)
prefs
.edit()
.putString(KEY_CURRENT, url)
.putString(KEY_RECENTS, recents.joinToString("\n"))
.apply()
}
private companion object {
const val PREFS = "ai.omnigent.android.servers"
const val KEY_CURRENT = "current_server_url"
const val KEY_RECENTS = "recent_server_urls"
const val MAX_RECENTS = 8
// 10.0.2.2 is the host loopback from the Android emulator.
const val DEFAULT_DEBUG_SERVER = "http://10.0.2.2:8000"
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Filled primary button — solid brand color, radius 8, with a press ripple.
Mirrors the iOS PrimaryButtonStyle (filled, rounded). -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#33FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/brand_primary" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Bordered text-field background — rounded rect with a 1dp brand border,
matching the iOS Connect screen's RoundedRectangle(radius: 8) stroke. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke android:width="1dp" android:color="@color/brand_border" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Recent-server row — same bordered look as the input, with a press ripple. -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/brand_border">
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke android:width="1dp" android:color="@color/brand_border" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<group android:scaleX="2.1875" android:scaleY="2.1875" android:translateX="19.0" android:translateY="19.0">
<path android:fillColor="#FFF53B9D" android:pathData="M6.51007 17.9576L6.49322 17.9456C6.25898 17.7762 5.89293 17.4372 5.67026 17.2281C4.57253 16.1967 3.15435 15.005 2.83918 13.4642C2.62922 12.4377 3.35237 11.6308 4.34151 11.4803C5.38586 11.3214 6.26236 11.7561 7.23953 12.028C8.33425 12.3325 9.79787 12.6425 10.8895 12.1914C12.0837 11.698 12.4413 9.84489 12.7575 8.70738C12.8908 8.24164 13.028 7.77702 13.169 7.31358C13.5802 5.92716 14.3049 3.39582 15.985 3.08909C16.8035 2.97722 17.3379 3.45458 17.7711 4.06981C18.2346 4.72777 18.371 5.32019 18.637 6.0426C19.2013 7.57495 19.3827 9.77259 20.2284 11.147C20.3817 11.3972 20.5818 11.6155 20.8178 11.79C21.9087 12.5881 23.8321 12.0885 24.9872 11.644C25.3253 11.513 25.6599 11.373 25.9903 11.2242C26.7936 10.8608 27.7906 10.3322 28.6299 10.9362C28.9332 11.1545 29.1476 11.6143 29.1713 11.9819C29.3224 14.3336 27.5117 16.1468 25.8868 17.559L25.1323 18.2077C24.7365 18.5463 24.4373 18.7853 24.1319 19.2203C23.4221 20.2311 23.7511 21.2787 24.1146 22.3425C24.5131 23.508 25.0053 24.6711 25.079 25.9166C25.123 26.6878 25.0581 27.5969 24.5683 28.2326C24.2842 28.6015 23.8663 28.8019 23.4072 28.846C21.6154 29.0007 19.8513 27.5183 18.4829 26.5312C17.9927 26.1777 17.412 25.73 16.8655 25.5209C16.5539 25.3989 16.2161 25.3594 15.8849 25.4066C15.0394 25.5237 14.2562 26.3096 13.5813 26.845C12.2244 27.9217 10.3481 29.3463 8.50291 28.7763C7.80387 28.5604 7.43431 27.7018 7.3967 27.0372C7.34883 26.3031 7.46057 25.4876 7.58344 24.761C7.56828 24.7493 7.52851 24.7301 7.51052 24.7241C7.13172 24.5968 6.81947 24.8986 6.54343 25.0921C6.11409 25.393 5.28688 26.0334 4.73625 25.7994C3.90411 25.446 4.47371 24.0554 4.58221 23.4115C4.60596 23.271 4.65317 23.119 4.6329 22.9789C4.61616 22.863 4.56787 22.7541 4.49331 22.6637C4.38261 22.5272 4.2289 22.4334 4.08866 22.32C3.69593 22.0025 3.12714 21.6339 2.90175 21.1716C2.82366 21.0114 2.81571 20.7923 2.88904 20.6308C3.20107 20.0028 4.23679 19.9699 4.83399 19.8928C5.39697 19.82 5.85532 19.864 6.03566 19.213C6.06559 19.105 6.1241 18.968 6.16178 18.8591C6.26728 18.5541 6.37701 18.2517 6.51007 17.9576Z"/>
<path android:fillColor="#FF4DC5A0" android:pathData="M6.50962 17.9575C6.55727 17.7826 6.83762 17.4232 7.02106 17.3388C7.5061 17.1156 7.83196 17.434 8.05515 17.832C8.37577 18.4039 8.45091 19.0554 8.79036 19.6046C8.85231 19.7049 9.01785 19.7929 9.12815 19.8256C9.4139 19.904 9.70314 19.9052 9.99219 19.9304C10.4094 19.9668 10.9765 20.0392 11.331 20.2605C11.4437 20.3308 11.5442 20.4544 11.6227 20.5538C11.6747 20.8972 11.6791 21.0685 11.4625 21.3731C11.3059 21.5933 11.1059 21.7679 10.9175 21.9579C10.7983 22.0434 10.7446 22.0779 10.6472 22.1872C9.83809 22.8411 9.94609 22.9594 10.2304 23.9416C10.3155 24.236 10.3274 24.5062 10.3927 24.7817L10.4123 24.9566C10.3911 25.0771 10.3662 25.3069 10.3161 25.4038C10.0246 25.9116 9.5524 25.8256 9.08649 25.6444C8.855 25.5706 8.29237 25.1976 8.07552 25.0552C7.89986 24.94 7.79451 24.8355 7.58298 24.761C7.56782 24.7493 7.52805 24.73 7.51006 24.724C7.13126 24.5967 6.81902 24.8985 6.54297 25.0921C6.11363 25.393 5.28642 26.0333 4.73579 25.7993C3.90366 25.4459 4.47325 24.0553 4.58175 23.4115C4.6055 23.271 4.65271 23.119 4.63244 22.9788C4.61571 22.8629 4.56741 22.754 4.49285 22.6637C4.38215 22.5272 4.22845 22.4333 4.0882 22.3199C3.69548 22.0025 3.12668 21.6338 2.9013 21.1715C2.8232 21.0113 2.81525 20.7922 2.88858 20.6307C3.20061 20.0028 4.23634 19.9699 4.83353 19.8927C5.39651 19.8199 5.85486 19.8639 6.0352 19.2129C6.06513 19.1049 6.12364 18.968 6.16132 18.8591C6.26682 18.554 6.37655 18.2516 6.50962 17.9575Z"/>
<path android:fillColor="#FF26A079" android:pathData="M5.27417 24.2656C5.9459 24.2273 5.74258 25.1074 5.24114 25.2452C5.15227 25.2478 5.06938 25.2547 4.99003 25.2154C4.66515 25.0546 4.80234 24.6065 5.0197 24.4128C5.11921 24.3241 5.15661 24.3057 5.27417 24.2656Z"/>
<path android:fillColor="#FF26A079" android:pathData="M9.25083 24.2661C9.34723 24.2576 9.38963 24.2538 9.48021 24.2924C9.65309 24.3677 9.78958 24.5077 9.86041 24.6824C9.95471 24.9205 9.92573 25.1374 9.66743 25.2294C9.61902 25.2348 9.57025 25.2365 9.52159 25.2343C9.14801 25.2186 9.00576 24.7901 9.024 24.4716C9.09574 24.365 9.14383 24.33 9.25083 24.2661Z"/>
<path android:fillColor="#FF26A079" android:pathData="M7.214 17.8819C7.28706 17.8762 7.36135 17.879 7.43477 17.88C7.73085 18.0599 7.73673 18.5255 7.40757 18.6759C6.9074 18.7893 6.87236 17.9953 7.214 17.8819Z"/>
<path android:fillColor="#FF26A079" android:pathData="M3.56352 20.6289C3.69302 20.6222 3.78885 20.6112 3.90753 20.677C4.11185 20.7901 4.12773 20.9771 4.02113 21.1651C3.97715 21.195 3.92139 21.226 3.87497 21.2533C3.42215 21.3343 3.16043 20.8312 3.56352 20.6289Z"/>
<path android:fillColor="#FF26A079" android:pathData="M10.7601 20.6129C10.8656 20.6062 10.9454 20.6154 11.0463 20.6449C11.2606 20.8974 11.2314 21.1458 10.8938 21.254C10.4131 21.273 10.3156 20.805 10.7601 20.6129Z"/>
<path android:fillColor="#FF000000" android:pathData="M6.85723 22.5388C7.10697 22.5309 6.97074 22.7785 7.16713 22.8928C7.24012 22.9312 7.37996 22.9257 7.45331 22.8995C7.6428 22.8321 7.54123 22.5841 7.75476 22.5234C7.8804 22.5515 7.86386 22.6971 7.84072 22.7921C7.82551 22.8339 7.80542 22.8827 7.78014 22.919C7.69238 23.047 7.5545 23.1316 7.40073 23.1522C7.24747 23.1746 7.0919 23.1313 6.97217 23.0331C6.82953 22.9168 6.71263 22.6982 6.85723 22.5388Z"/>
<path android:fillColor="#FF26A079" android:pathData="M7.19423 18.8583C7.33773 18.8489 7.50829 18.8589 7.58348 18.9966C7.70167 19.2129 7.62054 19.3894 7.42815 19.4975C7.35673 19.5033 7.28434 19.4989 7.21253 19.4967C7.0002 19.3753 7.00119 19.2848 7.00773 19.06C7.06663 18.9575 7.09554 18.9223 7.19423 18.8583Z"/>
<path android:fillColor="#FF26A079" android:pathData="M8.74876 23.6523C9.08317 23.6168 9.13865 23.8147 9.12276 24.0965C9.05097 24.1735 9.01742 24.2058 8.92252 24.251C8.78355 24.2567 8.65425 24.2289 8.57763 24.0996C8.53548 24.0282 8.52568 23.9423 8.55074 23.8634C8.5865 23.7479 8.64821 23.7038 8.74876 23.6523Z"/>
<path android:fillColor="#FF26A079" android:pathData="M5.77901 23.6513C6.13852 23.626 6.16922 23.8032 6.14472 24.1057C6.07399 24.1706 6.02658 24.1993 5.94617 24.2515C5.52114 24.2613 5.43035 23.846 5.77901 23.6513Z"/>
<path android:fillColor="#FF26A079" android:pathData="M4.42675 20.9641C4.80216 20.9605 4.87145 21.3487 4.54527 21.5182C4.48522 21.526 4.41978 21.5264 4.36362 21.501C4.29342 21.4687 4.24006 21.4083 4.21663 21.3347C4.18916 21.2519 4.1986 21.1613 4.24254 21.086C4.2906 21.0037 4.34021 20.9853 4.42675 20.9641Z"/>
<path android:fillColor="#FF26A079" android:pathData="M10.0554 20.9645C10.0676 20.9638 10.0798 20.9633 10.092 20.9631C10.3697 20.9568 10.3854 21.1363 10.3783 21.3574C10.3121 21.4496 10.2836 21.4749 10.1803 21.5205C10.1287 21.521 10.0905 21.5202 10.0399 21.5069C9.9669 21.4878 9.90557 21.4384 9.87144 21.371C9.77997 21.1936 9.89382 21.0401 10.0554 20.9645Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M10.3163 25.4038C10.9891 25.8676 10.8138 26.6957 10.4093 27.2732C10.0625 27.7684 9.32314 28.1964 8.75694 27.8078C8.17348 27.4071 8.41999 26.503 8.76236 26.0295C8.86252 25.891 8.98646 25.7749 9.08674 25.6444C9.55265 25.8256 10.0248 25.9116 10.3163 25.4038Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M10.3935 24.7817C10.3874 24.643 10.3863 24.5592 10.4401 24.4262C10.5582 24.1343 10.8106 23.9608 11.1267 23.9614C11.5252 23.9622 11.8189 24.2869 11.7955 24.6814C11.7742 25.1247 11.4298 25.4121 10.9921 25.3666C10.7503 25.3415 10.6257 25.2304 10.482 25.0492C10.4578 25.0192 10.4349 24.9882 10.4131 24.9565L10.3935 24.7817Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M10.9178 21.958C10.9086 22.1641 10.8136 22.1574 10.6475 22.1873C10.7449 22.078 10.7986 22.0435 10.9178 21.958Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M22.5454 25.2356C23.2724 25.1277 23.9092 25.986 23.9966 26.6286C24.0735 27.1949 23.8629 27.7645 23.2233 27.8341C22.8368 27.838 22.7077 27.7829 22.3881 27.5694C21.6232 27.0585 21.3259 25.3692 22.5454 25.2356Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M16.0342 4.15585C17.2757 4.04693 17.5094 5.95389 16.7941 6.64907C16.6361 6.80276 16.5365 6.8406 16.3255 6.88727C15.7664 6.98088 15.3881 6.58559 15.2317 6.09144C15.0831 5.63594 15.1258 5.13956 15.3501 4.71613C15.5131 4.40982 15.7055 4.25473 16.0342 4.15585Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M27.6097 11.6573C29.1399 11.6003 28.5498 13.8534 27.0588 14.0929C26.7107 14.0997 26.3379 14.018 26.1915 13.6517C25.8581 12.8178 26.7905 11.7178 27.6097 11.6573Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M4.47641 12.3426C6.04847 12.2478 6.86156 14.2535 5.36954 14.4839C3.81723 14.5382 2.96582 12.5727 4.47641 12.3426Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M16.0532 7.30834C16.562 7.22638 17.0403 7.57478 17.1182 8.08432C17.1962 8.59384 16.844 9.0693 16.3339 9.14323C15.8295 9.21634 15.3604 8.86892 15.2832 8.36504C15.2062 7.86115 15.5499 7.3894 16.0532 7.30834Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M16.049 9.57953C16.4819 9.49624 16.9009 9.77805 16.9869 10.2104C17.073 10.6428 16.7939 11.0636 16.362 11.1524C15.9263 11.242 15.5008 10.9598 15.414 10.5235C15.3272 10.0872 15.6121 9.66358 16.049 9.57953Z"/>
<path android:fillColor="#FF000000" android:pathData="M14.9698 18.5967C15.0599 18.5882 15.1392 18.5774 15.2143 18.6367C15.3222 18.7218 15.3282 18.8703 15.3596 18.9935C15.4333 19.2828 15.6337 19.4754 15.9267 19.5235C16.2923 19.5834 16.6704 19.4664 16.8258 19.1027C16.8987 18.9545 16.8609 18.7231 17.0167 18.6245C17.1389 18.547 17.3132 18.5851 17.3791 18.7182C17.4571 18.8751 17.4017 19.0803 17.3537 19.2378C17.1282 19.8454 16.5011 20.1798 15.8735 20.0728C15.5679 20.0244 15.1991 19.8195 15.0277 19.5529C14.8573 19.2879 14.6302 18.8178 14.9698 18.5967Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M25.226 13.6548C25.6232 13.6077 25.9837 13.8913 26.0313 14.2885C26.079 14.6858 25.7959 15.0466 25.3987 15.0948C25.0007 15.1432 24.639 14.8594 24.5912 14.4613C24.5434 14.0632 24.8278 13.7019 25.226 13.6548Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M20.2694 22.5289C20.6617 22.5076 20.9966 22.8089 21.0165 23.2013C21.0363 23.5937 20.7335 23.9274 20.3412 23.9457C19.9509 23.9639 19.6193 23.6631 19.5996 23.273C19.5798 22.8828 19.8793 22.5501 20.2694 22.5289Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M21.2865 23.9748C21.6671 23.8896 22.0449 24.128 22.1321 24.5081C22.2192 24.8882 21.9828 25.2673 21.6031 25.3565C21.2207 25.4461 20.8382 25.2077 20.7505 24.8247C20.6627 24.4419 20.9031 24.0606 21.2865 23.9748Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M11.9983 22.5536C12.3761 22.4571 12.7605 22.6856 12.8562 23.0636C12.9519 23.4417 12.7225 23.8255 12.3443 23.9204C11.9673 24.0149 11.5849 23.7863 11.4895 23.4095C11.3941 23.0327 11.6217 22.6499 11.9983 22.5536Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M23.347 14.5889C23.7213 14.509 24.0896 14.7471 24.1703 15.1212C24.2511 15.4953 24.0138 15.8641 23.6399 15.9457C23.2648 16.0275 22.8946 15.7893 22.8136 15.4141C22.7326 15.0388 22.9716 14.6691 23.347 14.5889Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M6.90794 13.8148C7.2844 13.7828 7.61654 14.0596 7.653 14.4357C7.68945 14.8117 7.41667 15.1471 7.04107 15.1881C6.79408 15.215 6.55169 15.1071 6.40647 14.9055C6.26126 14.7039 6.23567 14.4397 6.33946 14.214C6.44323 13.9883 6.66039 13.8358 6.90794 13.8148Z"/>
<path android:fillColor="#FFE60C7F" android:pathData="M8.54601 14.576C8.91968 14.5574 9.23708 14.8466 9.25325 15.2204C9.26943 15.5941 8.97818 15.9096 8.6043 15.9234C8.23388 15.9369 7.92195 15.649 7.90592 15.2787C7.8899 14.9083 8.17579 14.5945 8.54601 14.576Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M19.1968 14.0418C20.4289 13.9606 21.4922 14.8969 21.5679 16.1298C21.6437 17.3627 20.7029 18.4222 19.4701 18.4924C18.245 18.5624 17.1938 17.6285 17.1185 16.4033C17.0433 15.178 17.9722 14.1224 19.1968 14.0418Z"/>
<path android:fillColor="#FF000000" android:pathData="M19.3719 14.5197C20.3367 14.5329 21.1083 15.3257 21.0954 16.2908C21.0825 17.2559 20.2901 18.028 19.3253 18.0154C18.3601 18.0029 17.5879 17.2098 17.6007 16.2443C17.6136 15.2787 18.4067 14.5065 19.3719 14.5197Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M18.5956 15.1172C18.8915 15.0795 19.1627 15.287 19.2037 15.5826C19.2446 15.8782 19.0401 16.1517 18.745 16.1959C18.5513 16.2249 18.357 16.1475 18.2361 15.9933C18.1152 15.8391 18.0865 15.6319 18.1609 15.4506C18.2353 15.2694 18.4013 15.142 18.5956 15.1172Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M12.7788 14.0418C14.0109 13.9606 15.0743 14.8969 15.15 16.1298C15.2257 17.3627 14.285 18.4222 13.0521 18.4924C11.827 18.5624 10.7758 17.6285 10.7006 16.4033C10.6253 15.178 11.5543 14.1224 12.7788 14.0418Z"/>
<path android:fillColor="#FF000000" android:pathData="M12.9539 14.5197C13.9187 14.5329 14.6903 15.3257 14.6774 16.2908C14.6646 17.2559 13.8722 18.028 12.9074 18.0154C11.9421 18.0029 11.1699 17.2098 11.1828 16.2443C11.1956 15.2787 11.9887 14.5065 12.9539 14.5197Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M12.1776 15.1172C12.4735 15.0795 12.7447 15.287 12.7857 15.5826C12.8267 15.8782 12.6221 16.1517 12.3271 16.1959C12.1334 16.2249 11.939 16.1475 11.8181 15.9933C11.6973 15.8391 11.6685 15.6319 11.7429 15.4506C11.8173 15.2694 11.9833 15.142 12.1776 15.1172Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M6.01442 20.5171C6.55973 20.4811 7.03032 20.8955 7.06383 21.4412C7.09734 21.9868 6.68101 22.4557 6.1354 22.4868C5.59318 22.5178 5.12797 22.1044 5.09466 21.5622C5.06136 21.0199 5.47249 20.5528 6.01442 20.5171Z"/>
<path android:fillColor="#FF000000" android:pathData="M6.09154 20.7286C6.51853 20.7344 6.86 21.0853 6.85431 21.5124C6.84862 21.9396 6.49793 22.2812 6.07094 22.2757C5.64375 22.2701 5.302 21.9191 5.30769 21.4918C5.31338 21.0645 5.66436 20.7228 6.09154 20.7286Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M5.7485 20.9932C5.87948 20.9765 5.9995 21.0683 6.01762 21.1991C6.03576 21.33 5.94524 21.451 5.81466 21.4705C5.72892 21.4834 5.64291 21.4492 5.58942 21.3809C5.53593 21.3127 5.52321 21.2209 5.55613 21.1407C5.58904 21.0605 5.66251 21.0042 5.7485 20.9932Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M8.48512 20.5171C9.03043 20.4811 9.50102 20.8955 9.53453 21.4412C9.56804 21.9868 9.15171 22.4557 8.6061 22.4868C8.06389 22.5178 7.59867 22.1044 7.56537 21.5622C7.53206 21.0199 7.94319 20.5528 8.48512 20.5171Z"/>
<path android:fillColor="#FF000000" android:pathData="M8.56224 20.7286C8.98924 20.7344 9.3307 21.0853 9.32501 21.5124C9.31932 21.9396 8.96863 22.2812 8.54164 22.2757C8.11446 22.2701 7.7727 21.9191 7.77839 21.4918C7.78409 21.0645 8.13507 20.7228 8.56224 20.7286Z"/>
<path android:fillColor="#FFFEFEFE" android:pathData="M8.21921 20.9932C8.35018 20.9765 8.4702 21.0683 8.48833 21.1991C8.50646 21.33 8.41594 21.451 8.28536 21.4705C8.19963 21.4834 8.11361 21.4492 8.06012 21.3809C8.00663 21.3127 7.99392 21.2209 8.02683 21.1407C8.05975 21.0605 8.13321 21.0042 8.21921 20.9932Z"/>
</group>
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<group android:scaleX="2.1875" android:scaleY="2.1875" android:translateX="19" android:translateY="19">
<path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M6.51007 17.9576L6.49322 17.9456C6.25898 17.7762 5.89293 17.4372 5.67026 17.2281C4.57253 16.1967 3.15435 15.005 2.83918 13.4642C2.62922 12.4377 3.35237 11.6308 4.34151 11.4803C5.38586 11.3214 6.26236 11.7561 7.23953 12.028C8.33425 12.3325 9.79787 12.6425 10.8895 12.1914C12.0837 11.698 12.4413 9.84489 12.7575 8.70738C12.8908 8.24164 13.028 7.77702 13.169 7.31358C13.5802 5.92716 14.3049 3.39582 15.985 3.08909C16.8035 2.97722 17.3379 3.45458 17.7711 4.06981C18.2346 4.72777 18.371 5.32019 18.637 6.0426C19.2013 7.57495 19.3827 9.77259 20.2284 11.147C20.3817 11.3972 20.5818 11.6155 20.8178 11.79C21.9087 12.5881 23.8321 12.0885 24.9872 11.644C25.3253 11.513 25.6599 11.373 25.9903 11.2242C26.7936 10.8608 27.7906 10.3322 28.6299 10.9362C28.9332 11.1545 29.1476 11.6143 29.1713 11.9819C29.3224 14.3336 27.5117 16.1468 25.8868 17.559L25.1323 18.2077C24.7365 18.5463 24.4373 18.7853 24.1319 19.2203C23.4221 20.2311 23.7511 21.2787 24.1146 22.3425C24.5131 23.508 25.0053 24.6711 25.079 25.9166C25.123 26.6878 25.0581 27.5969 24.5683 28.2326C24.2842 28.6015 23.8663 28.8019 23.4072 28.846C21.6154 29.0007 19.8513 27.5183 18.4829 26.5312C17.9927 26.1777 17.412 25.73 16.8655 25.5209C16.5539 25.3989 16.2161 25.3594 15.8849 25.4066C15.0394 25.5237 14.2562 26.3096 13.5813 26.845C12.2244 27.9217 10.3481 29.3463 8.50291 28.7763C7.80387 28.5604 7.43431 27.7018 7.3967 27.0372C7.34883 26.3031 7.46057 25.4876 7.58344 24.761C7.56828 24.7493 7.52851 24.7301 7.51052 24.7241C7.13172 24.5968 6.81947 24.8986 6.54343 25.0921C6.11409 25.393 5.28688 26.0334 4.73625 25.7994C3.90411 25.446 4.47371 24.0554 4.58221 23.4115C4.60596 23.271 4.65317 23.119 4.6329 22.9789C4.61616 22.863 4.56787 22.7541 4.49331 22.6637C4.38261 22.5272 4.2289 22.4334 4.08866 22.32C3.69593 22.0025 3.12714 21.6339 2.90175 21.1716C2.82366 21.0114 2.81571 20.7923 2.88904 20.6308C3.20107 20.0028 4.23679 19.9699 4.83399 19.8928C5.39697 19.82 5.85532 19.864 6.03566 19.213C6.06559 19.105 6.1241 18.968 6.16178 18.8591C6.26728 18.5541 6.37701 18.2517 6.51007 17.9576Z M19.1968 14.0418C20.4289 13.9606 21.4922 14.8969 21.5679 16.1298C21.6437 17.3627 20.7029 18.4222 19.4701 18.4924C18.245 18.5624 17.1938 17.6285 17.1185 16.4033C17.0433 15.178 17.9722 14.1224 19.1968 14.0418Z M12.7788 14.0418C14.0109 13.9606 15.0743 14.8969 15.15 16.1298C15.2257 17.3627 14.285 18.4222 13.0521 18.4924C11.827 18.5624 10.7758 17.6285 10.7006 16.4033C10.6253 15.178 11.5543 14.1224 12.7788 14.0418Z M14.9698 18.5967C15.0599 18.5882 15.1392 18.5774 15.2143 18.6367C15.3222 18.7218 15.3282 18.8703 15.3596 18.9935C15.4333 19.2828 15.6337 19.4754 15.9267 19.5235C16.2923 19.5834 16.6704 19.4664 16.8258 19.1027C16.8987 18.9545 16.8609 18.7231 17.0167 18.6245C17.1389 18.547 17.3132 18.5851 17.3791 18.7182C17.4571 18.8751 17.4017 19.0803 17.3537 19.2378C17.1282 19.8454 16.5011 20.1798 15.8735 20.0728C15.5679 20.0244 15.1991 19.8195 15.0277 19.5529C14.8573 19.2879 14.6302 18.8178 14.9698 18.5967Z M6.50962 17.9575C6.55727 17.7826 6.83762 17.4232 7.02106 17.3388C7.5061 17.1156 7.83196 17.434 8.05515 17.832C8.37577 18.4039 8.45091 19.0554 8.79036 19.6046C8.85231 19.7049 9.01785 19.7929 9.12815 19.8256C9.4139 19.904 9.70314 19.9052 9.99219 19.9304C10.4094 19.9668 10.9765 20.0392 11.331 20.2605C11.4437 20.3308 11.5442 20.4544 11.6227 20.5538C11.6747 20.8972 11.6791 21.0685 11.4625 21.3731C11.3059 21.5933 11.1059 21.7679 10.9175 21.9579C10.7983 22.0434 10.7446 22.0779 10.6472 22.1872C9.83809 22.8411 9.94609 22.9594 10.2304 23.9416C10.3155 24.236 10.3274 24.5062 10.3927 24.7817L10.4123 24.9566C10.3911 25.0771 10.3662 25.3069 10.3161 25.4038C10.0246 25.9116 9.5524 25.8256 9.08649 25.6444C8.855 25.5706 8.29237 25.1976 8.07552 25.0552C7.89986 24.94 7.79451 24.8355 7.58298 24.761C7.56782 24.7493 7.52805 24.73 7.51006 24.724C7.13126 24.5967 6.81902 24.8985 6.54297 25.0921C6.11363 25.393 5.28642 26.0333 4.73579 25.7993C3.90366 25.4459 4.47325 24.0553 4.58175 23.4115C4.6055 23.271 4.65271 23.119 4.63244 22.9788C4.61571 22.8629 4.56741 22.754 4.49285 22.6637C4.38215 22.5272 4.22845 22.4333 4.0882 22.3199C3.69548 22.0025 3.12668 21.6338 2.9013 21.1715C2.8232 21.0113 2.81525 20.7922 2.88858 20.6307C3.20061 20.0028 4.23634 19.9699 4.83353 19.8927C5.39651 19.8199 5.85486 19.8639 6.0352 19.2129C6.06513 19.1049 6.12364 18.968 6.16132 18.8591C6.26682 18.554 6.37655 18.2516 6.50962 17.9575Z"/>
<path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M19.3719 14.5197C20.3367 14.5329 21.1083 15.3257 21.0954 16.2908C21.0825 17.2559 20.2901 18.028 19.3253 18.0154C18.3601 18.0029 17.5879 17.2098 17.6007 16.2443C17.6136 15.2787 18.4067 14.5065 19.3719 14.5197Z M18.221 15.739 a0.599 0.599 0 1 0 1.197 0 a0.599 0.599 0 1 0 -1.197 0 Z M12.9539 14.5197C13.9187 14.5329 14.6903 15.3257 14.6774 16.2908C14.6646 17.2559 13.8722 18.028 12.9074 18.0154C11.9421 18.0029 11.1699 17.2098 11.1828 16.2443C11.1956 15.2787 11.9887 14.5065 12.9539 14.5197Z M11.803 15.739 a0.599 0.599 0 1 0 1.197 0 a0.599 0.599 0 1 0 -1.197 0 Z"/>
<group android:pivotX="7.247" android:pivotY="21.574" android:scaleX="0.8" android:scaleY="0.8">
<path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M6.50962 17.9575C6.55727 17.7826 6.83762 17.4232 7.02106 17.3388C7.5061 17.1156 7.83196 17.434 8.05515 17.832C8.37577 18.4039 8.45091 19.0554 8.79036 19.6046C8.85231 19.7049 9.01785 19.7929 9.12815 19.8256C9.4139 19.904 9.70314 19.9052 9.99219 19.9304C10.4094 19.9668 10.9765 20.0392 11.331 20.2605C11.4437 20.3308 11.5442 20.4544 11.6227 20.5538C11.6747 20.8972 11.6791 21.0685 11.4625 21.3731C11.3059 21.5933 11.1059 21.7679 10.9175 21.9579C10.7983 22.0434 10.7446 22.0779 10.6472 22.1872C9.83809 22.8411 9.94609 22.9594 10.2304 23.9416C10.3155 24.236 10.3274 24.5062 10.3927 24.7817L10.4123 24.9566C10.3911 25.0771 10.3662 25.3069 10.3161 25.4038C10.0246 25.9116 9.5524 25.8256 9.08649 25.6444C8.855 25.5706 8.29237 25.1976 8.07552 25.0552C7.89986 24.94 7.79451 24.8355 7.58298 24.761C7.56782 24.7493 7.52805 24.73 7.51006 24.724C7.13126 24.5967 6.81902 24.8985 6.54297 25.0921C6.11363 25.393 5.28642 26.0333 4.73579 25.7993C3.90366 25.4459 4.47325 24.0553 4.58175 23.4115C4.6055 23.271 4.65271 23.119 4.63244 22.9788C4.61571 22.8629 4.56741 22.754 4.49285 22.6637C4.38215 22.5272 4.22845 22.4333 4.0882 22.3199C3.69548 22.0025 3.12668 21.6338 2.9013 21.1715C2.8232 21.0113 2.81525 20.7922 2.88858 20.6307C3.20061 20.0028 4.23634 19.9699 4.83353 19.8927C5.39651 19.8199 5.85486 19.8639 6.0352 19.2129C6.06513 19.1049 6.12364 18.968 6.16132 18.8591C6.26682 18.554 6.37655 18.2516 6.50962 17.9575Z M8.48512 20.5171C9.03043 20.4811 9.50102 20.8955 9.53453 21.4412C9.56804 21.9868 9.15171 22.4557 8.6061 22.4868C8.06389 22.5178 7.59867 22.1044 7.56537 21.5622C7.53206 21.0199 7.94319 20.5528 8.48512 20.5171Z M6.01442 20.5171C6.55973 20.4811 7.03032 20.8955 7.06383 21.4412C7.09734 21.9868 6.68101 22.4557 6.1354 22.4868C5.59318 22.5178 5.12797 22.1044 5.09466 21.5622C5.06136 21.0199 5.47249 20.5528 6.01442 20.5171Z M6.85723 22.5388C7.10697 22.5309 6.97074 22.7785 7.16713 22.8928C7.24012 22.9312 7.37996 22.9257 7.45331 22.8995C7.6428 22.8321 7.54123 22.5841 7.75476 22.5234C7.8804 22.5515 7.86386 22.6971 7.84072 22.7921C7.82551 22.8339 7.80542 22.8827 7.78014 22.919C7.69238 23.047 7.5545 23.1316 7.40073 23.1522C7.24747 23.1746 7.0919 23.1313 6.97217 23.0331C6.82953 22.9168 6.71263 22.6982 6.85723 22.5388Z"/>
<path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M8.56224 20.7286C8.98924 20.7344 9.3307 21.0853 9.32501 21.5124C9.31932 21.9396 8.96863 22.2812 8.54164 22.2757C8.11446 22.2701 7.7727 21.9191 7.77839 21.4918C7.78409 21.0645 8.13507 20.7228 8.56224 20.7286Z M8.053 21.268 a0.265 0.265 0 1 0 0.530 0 a0.265 0.265 0 1 0 -0.530 0 Z M6.09154 20.7286C6.51853 20.7344 6.86 21.0853 6.85431 21.5124C6.84862 21.9396 6.49793 22.2812 6.07094 22.2757C5.64375 22.2701 5.302 21.9191 5.30769 21.4918C5.31338 21.0645 5.66436 20.7228 6.09154 20.7286Z M5.582 21.268 a0.265 0.265 0 1 0 0.530 0 a0.265 0.265 0 1 0 -0.530 0 Z"/>
</group>
</group>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#000000"
android:pathData="M12,2L2,7v10l10,5 10,-5V7L12,2zM12,4.2l6.9,3.45L12,11.1 5.1,7.65 12,4.2zM4,9.3l7,3.5v6.9l-7,-3.5V9.3zM13,19.7v-6.9l7,-3.5v6.9l-7,3.5z" />
</vector>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Mirrors the iOS shell's ConnectView: brand wordmark (which embeds the starfish)
on top, muted subtitle, a "Server URL" label + bordered field, a filled primary
button, an inline error line, and bordered recent-server rows. Brand colors come
from values[-night]/colors.xml (ported from the iOS DesignTokens); type uses the
system font (Roboto) — the same native-font choice the web UI and iOS make.
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="600dp"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingVertical="24dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:adjustViewBounds="true"
android:contentDescription="@string/connect_logo_desc"
android:maxHeight="56dp"
android:src="@drawable/omnigents_logo" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:gravity="center"
android:lineSpacingExtra="2sp"
android:text="@string/connect_subtitle"
android:textColor="@color/brand_muted_foreground"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:fontFamily="sans-serif-medium"
android:text="@string/connect_label"
android:textColor="@color/brand_foreground"
android:textSize="14sp" />
<EditText
android:id="@+id/server_url"
android:layout_width="match_parent"
android:layout_height="48dp"
android:autofillHints="url"
android:background="@drawable/bg_input"
android:hint="@string/connect_hint"
android:imeOptions="actionGo"
android:importantForAutofill="no"
android:inputType="textUri"
android:paddingHorizontal="12dp"
android:textColor="@color/brand_foreground"
android:textColorHint="@color/brand_muted_foreground"
android:textSize="14sp" />
<Button
android:id="@+id/connect"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_button_primary"
android:fontFamily="sans-serif-medium"
android:stateListAnimator="@null"
android:text="@string/connect_action"
android:textAllCaps="false"
android:textColor="@color/brand_on_primary"
android:textSize="14sp" />
<TextView
android:id="@+id/error_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:lineSpacingExtra="2sp"
android:textColor="@color/brand_error"
android:textSize="13sp"
android:visibility="gone" />
<TextView
android:id="@+id/recents_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="8dp"
android:fontFamily="sans-serif-medium"
android:text="@string/connect_recents"
android:textColor="@color/brand_muted_foreground"
android:textSize="13sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/recents"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A single recent-server row: bordered, truncated in the middle, like iOS. -->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_recent"
android:ellipsize="middle"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:singleLine="true"
android:textColor="@color/brand_foreground"
android:textSize="14sp" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Brand palette (dark scheme), ported from the iOS DesignTokens dark values. -->
<resources>
<color name="ic_launcher_background">#1E2742</color>
<color name="brand_background">#1E1927</color>
<color name="brand_foreground">#E8ECF0</color>
<color name="brand_muted_foreground">#92A4B3</color>
<color name="brand_border">#37383B</color>
<color name="brand_primary">#E8ECF0</color>
<color name="brand_on_primary">#11171C</color>
<color name="brand_error">#C8324C</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Brand palette ported from the iOS shell's DesignTokens (light scheme) so the
Connect screen matches the iOS setup screen and the web UI. Dark values live
in values-night/colors.xml.
-->
<resources>
<!-- App-icon background: the brand dark-navy (iOS Icon Composer gradient). -->
<color name="ic_launcher_background">#1E2742</color>
<color name="brand_background">#FFFFFF</color>
<color name="brand_foreground">#11171C</color>
<color name="brand_muted_foreground">#6F6F6F</color>
<color name="brand_border">#E8ECF0</color>
<color name="brand_primary">#11171C</color>
<color name="brand_on_primary">#FFFFFF</color>
<color name="brand_error">#C8324C</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Omnigent</string>
<string name="notification_channel_name">Sessions</string>
<string name="signed_in_title">Signed in to Omnigent</string>
<string name="signed_in_body">Tap to return to the app.</string>
<plurals name="badge_text">
<item quantity="one">%1$d pending</item>
<item quantity="other">%1$d pending</item>
</plurals>
<string name="connect_logo_desc">Omnigent</string>
<string name="connect_subtitle">Enter the URL of the Omnigent server. The Android app loads its web UI directly.</string>
<string name="connect_label">Server URL</string>
<string name="connect_hint">https://omnigent.example.com</string>
<string name="connect_action">Connect</string>
<string name="connect_recents">Recent servers</string>
<string name="connect_invalid">Enter a valid http(s) server URL</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Edge-to-edge is driven in code (WindowCompat.setDecorFitsSystemWindows).
The cutout mode lets the WebView extend into the display cutout so
injected safe-area insets cover notched devices.
-->
<style name="Theme.Omnigent" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Release default: HTTPS only (mirrors iOS App Transport Security defaults).
Debug builds additionally permit cleartext to localhost / emulator loopback /
private LAN ranges for local development, matching the iOS shell's debug-only
NSAllowsArbitraryLoadsInWebContent. The debug overlay lives in
app/src/debug/res/xml/network_security_config.xml.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Guarded wrapper around `ktlint`, invoked by the android-ktlint-* pre-commit
# hooks. On CI the lint workflow installs ktlint before running pre-commit.
# On developer machines, install ktlint (e.g. `brew install ktlint`) to enable
# the hooks locally; the wrapper exits 0 cleanly if ktlint is absent so that a
# missing binary doesn't block commits on machines without it installed.
set -euo pipefail
if ! command -v ktlint >/dev/null 2>&1; then
exit 0
fi
exec ktlint "$@"
+4
View File
@@ -0,0 +1,4 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+19
View File
@@ -0,0 +1,19 @@
# Versions are pinned to the compileSdk-35 / Gradle-8.9 / JDK-17 toolchain this
# module builds against. AGP 9.x and the newest androidx libs require Gradle 9 /
# compileSdk 36, so lint's "newer version available" advisories are expected
# here and deliberately not chased — bump the whole toolchain together instead.
[versions]
agp = "8.6.1"
kotlin = "2.0.20"
androidxCore = "1.13.1"
androidxActivity = "1.9.2"
androidxWebkit = "1.12.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidxActivity" }
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "androidxWebkit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Omnigent"
include(":app")