Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad92e93660 | ||
|
|
bcebc1dbcf | ||
|
|
07383624fc | ||
|
|
898bcb792b |
8 changed files with 227 additions and 25 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
local.properties
|
local.properties
|
||||||
|
keystore.properties
|
||||||
*.iml
|
*.iml
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -41,16 +41,27 @@ Or just open the project in Android Studio.
|
||||||
|
|
||||||
### Release signing
|
### Release signing
|
||||||
|
|
||||||
Create a keystore and export it as Gradle properties or use
|
The published APKs are signed with the project's release keystore
|
||||||
`apksigner` on the release output:
|
(`keystore.properties` + the `.jks` file, both outside git). To build them
|
||||||
|
yourself, create a keystore and point `keystore.properties` at it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
keytool -genkeypair -v -keystore nikogpt.jks -alias nikogpt \
|
keytool -genkeypair -v -keystore nikogpt.jks -alias nikogpt \
|
||||||
-keyalg RSA -keysize 2048 -validity 10000
|
-keyalg RSA -keysize 2048 -validity 10000
|
||||||
apksigner sign --ks nikogpt.jks --out nikogpt-release.apk \
|
cat > keystore.properties <<EOF
|
||||||
app/build/outputs/apk/release/app-release-unsigned.apk
|
storeFile=../nikogpt.jks
|
||||||
|
storePassword=...
|
||||||
|
keyAlias=nikogpt
|
||||||
|
keyPassword=...
|
||||||
|
EOF
|
||||||
|
./gradlew assembleRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Installing over a debug-signed build requires uninstalling it first** (the
|
||||||
|
signatures differ). Add the release certificate's SHA-1 to the Android OAuth
|
||||||
|
client (Google Cloud) and its SHA-256 to `web.android_cert_sha256` in the bot
|
||||||
|
config for App Links.
|
||||||
|
|
||||||
## Google sign-in
|
## Google sign-in
|
||||||
|
|
||||||
Native "Continue with Google" is attempted in this order:
|
Native "Continue with Google" is attempted in this order:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release signing: keystore.properties (gitignored) points at the keystore.
|
||||||
|
// Without it, release builds stay unsigned and CI can sign them itself.
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val hasKeystore = keystorePropertiesFile.exists()
|
||||||
|
if (hasKeystore) {
|
||||||
|
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "dev.n1ko.nikogpt"
|
namespace = "dev.n1ko.nikogpt"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
|
|
@ -11,8 +22,19 @@ android {
|
||||||
applicationId = "dev.n1ko.nikogpt"
|
applicationId = "dev.n1ko.nikogpt"
|
||||||
minSdk = 29 // Android 10
|
minSdk = 29 // Android 10
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 4
|
versionCode = 8
|
||||||
versionName = "1.0.4"
|
versionName = "1.2.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (hasKeystore) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
@ -23,6 +45,9 @@ android {
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
if (hasKeystore) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Voice input in the web chat (MediaRecorder + server-side recognition). -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
@ -22,15 +24,36 @@
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Google sign-in handoff: after the browser finishes OAuth the
|
<!-- Google sign-in handoff (custom scheme fallback). -->
|
||||||
server redirects to nikogpt://auth?code=… and the app
|
|
||||||
exchanges the one-time code inside its own WebView. -->
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="nikogpt" android:host="auth" />
|
<data android:scheme="nikogpt" android:host="auth" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Android App Link: verified by /.well-known/assetlinks.json. -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="chat.n1ko.dev" android:pathPrefix="/app-auth" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- "Share to NikoGPT" from other apps. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="dev.n1ko.nikogpt.files"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package dev.n1ko.nikogpt
|
package dev.n1ko.nikogpt
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.AlertDialog
|
||||||
import android.app.DownloadManager
|
import android.app.DownloadManager
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.net.http.SslError
|
import android.net.http.SslError
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
|
@ -13,6 +16,7 @@ import android.view.View
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.DownloadListener
|
import android.webkit.DownloadListener
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.PermissionRequest
|
||||||
import android.webkit.SslErrorHandler
|
import android.webkit.SslErrorHandler
|
||||||
import android.webkit.URLUtil
|
import android.webkit.URLUtil
|
||||||
import android.webkit.ValueCallback
|
import android.webkit.ValueCallback
|
||||||
|
|
@ -31,6 +35,8 @@ import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
@ -47,6 +53,7 @@ import com.google.android.gms.common.GoogleApiAvailability
|
||||||
import com.google.android.gms.common.api.ApiException
|
import com.google.android.gms.common.api.ApiException
|
||||||
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,7 +68,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
private const val START_URL = "https://chat.n1ko.dev/"
|
private const val START_URL = "https://chat.n1ko.dev/"
|
||||||
private const val HOST = "chat.n1ko.dev"
|
private const val HOST = "chat.n1ko.dev"
|
||||||
private const val UA_SUFFIX = " NikoGPT-Android/1.0"
|
// The web app reads this marker to switch to the app handoff flow.
|
||||||
|
private val UA_SUFFIX = " NikoGPT-Android/" + BuildConfig.VERSION_NAME
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var webView: WebView
|
private lateinit var webView: WebView
|
||||||
|
|
@ -70,6 +78,15 @@ class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var errorBox: LinearLayout
|
private lateinit var errorBox: LinearLayout
|
||||||
private lateinit var errorText: TextView
|
private lateinit var errorText: TextView
|
||||||
|
|
||||||
|
// Shared text ("Share to NikoGPT") waiting for the page to load.
|
||||||
|
private var pendingShare: String? = null
|
||||||
|
// Whether the chat's inner scroll container is at the top; pull-to-refresh
|
||||||
|
// only fires there (the WebView itself never scrolls).
|
||||||
|
@Volatile
|
||||||
|
private var webAtTop = true
|
||||||
|
// Where the camera writes the picture before it reaches the web app.
|
||||||
|
private var pendingCameraUri: Uri? = null
|
||||||
|
|
||||||
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
||||||
private val filePicker: ActivityResultLauncher<Array<String>> =
|
private val filePicker: ActivityResultLauncher<Array<String>> =
|
||||||
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||||
|
|
@ -82,16 +99,48 @@ class MainActivity : AppCompatActivity() {
|
||||||
private val legacySignIn: ActivityResultLauncher<Intent> =
|
private val legacySignIn: ActivityResultLauncher<Intent> =
|
||||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
val link = legacyLinkMode
|
val link = legacyLinkMode
|
||||||
try {
|
val account = try {
|
||||||
val account = GoogleSignIn.getSignedInAccountFromIntent(result.data)
|
GoogleSignIn.getSignedInAccountFromIntent(result.data)
|
||||||
.getResult(ApiException::class.java)
|
.getResult(ApiException::class.java)
|
||||||
val token = account?.idToken
|
|
||||||
if (!token.isNullOrBlank()) deliverToken(token, link) else openGoogleInBrowser(link)
|
|
||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
|
// 12501/16 = the user closed the picker: stay in the app.
|
||||||
|
if (e.statusCode == 12501 || e.statusCode == 16) return@registerForActivityResult
|
||||||
|
Toast.makeText(this, getString(R.string.google_error_code, e.statusCode), Toast.LENGTH_LONG).show()
|
||||||
openGoogleInBrowser(link)
|
openGoogleInBrowser(link)
|
||||||
|
return@registerForActivityResult
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
openGoogleInBrowser(link)
|
openGoogleInBrowser(link)
|
||||||
|
return@registerForActivityResult
|
||||||
}
|
}
|
||||||
|
val token = account?.idToken
|
||||||
|
if (token.isNullOrBlank()) {
|
||||||
|
// microG accepted the account but could not mint an ID token
|
||||||
|
// (e.g. device registration is off): say so, then fall back.
|
||||||
|
Toast.makeText(this, getString(R.string.google_no_token), Toast.LENGTH_LONG).show()
|
||||||
|
openGoogleInBrowser(link)
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
deliverToken(token, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime microphone permission, bridged to the WebView.
|
||||||
|
private var pendingWebPermission: PermissionRequest? = null
|
||||||
|
private val audioPermission: ActivityResultLauncher<String> =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
val request = pendingWebPermission
|
||||||
|
pendingWebPermission = null
|
||||||
|
if (request == null) return@registerForActivityResult
|
||||||
|
if (granted) request.grant(request.resources) else request.deny()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera capture for the attach button.
|
||||||
|
private val takePicture: ActivityResultLauncher<Uri> =
|
||||||
|
registerForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
|
||||||
|
val cb = fileCallback
|
||||||
|
fileCallback = null
|
||||||
|
val uri = pendingCameraUri
|
||||||
|
pendingCameraUri = null
|
||||||
|
if (ok && uri != null) cb?.onReceiveValue(arrayOf(uri)) else cb?.onReceiveValue(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
|
@ -142,7 +191,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
webView.webViewClient = object : WebViewClient() {
|
webView.webViewClient = object : WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
val url = request.url
|
val url = request.url
|
||||||
if (url.scheme == "nikogpt" && url.host == "auth") {
|
if (isAuthUrl(url)) {
|
||||||
// Deep link that reached the WebView instead of the browser.
|
// Deep link that reached the WebView instead of the browser.
|
||||||
handleAuthUri(url)
|
handleAuthUri(url)
|
||||||
return true
|
return true
|
||||||
|
|
@ -171,10 +220,27 @@ class MainActivity : AppCompatActivity() {
|
||||||
progress.visibility = View.GONE
|
progress.visibility = View.GONE
|
||||||
// Persist cookies (the session) right after the page settles.
|
// Persist cookies (the session) right after the page settles.
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
|
maybeDeliverShare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
|
if (request.resources.none { it == PermissionRequest.RESOURCE_AUDIO_CAPTURE }) {
|
||||||
|
request.deny()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this@MainActivity, Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
request.grant(request.resources)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingWebPermission = request
|
||||||
|
audioPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||||
progress.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
|
progress.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
|
||||||
progress.progress = newProgress
|
progress.progress = newProgress
|
||||||
|
|
@ -187,13 +253,25 @@ class MainActivity : AppCompatActivity() {
|
||||||
): Boolean {
|
): Boolean {
|
||||||
fileCallback?.onReceiveValue(null)
|
fileCallback?.onReceiveValue(null)
|
||||||
fileCallback = callback
|
fileCallback = callback
|
||||||
return try {
|
AlertDialog.Builder(this@MainActivity)
|
||||||
filePicker.launch(arrayOf("*/*"))
|
.setItems(arrayOf(getString(R.string.attach_file), getString(R.string.attach_camera))) { _, which ->
|
||||||
true
|
if (which == 0) {
|
||||||
} catch (e: ActivityNotFoundException) {
|
try {
|
||||||
fileCallback = null
|
filePicker.launch(arrayOf("*/*"))
|
||||||
false
|
} catch (e: ActivityNotFoundException) {
|
||||||
}
|
fileCallback?.onReceiveValue(null)
|
||||||
|
fileCallback = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
openCamera()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setOnCancelListener {
|
||||||
|
fileCallback?.onReceiveValue(null)
|
||||||
|
fileCallback = null
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +280,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
})
|
})
|
||||||
|
|
||||||
refresh.setOnRefreshListener { webView.reload() }
|
refresh.setOnRefreshListener { webView.reload() }
|
||||||
|
// Pull-to-refresh only when the chat list is really at the top; a
|
||||||
|
// bigger trigger distance keeps normal scrolling from reloading.
|
||||||
|
refresh.setOnChildScrollUpCallback { _, _ -> !webAtTop }
|
||||||
|
refresh.setDistanceToTriggerSync((80 * resources.displayMetrics.density).toInt())
|
||||||
|
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
|
|
@ -215,13 +297,53 @@ class MainActivity : AppCompatActivity() {
|
||||||
webView.restoreState(savedInstanceState)
|
webView.restoreState(savedInstanceState)
|
||||||
}
|
}
|
||||||
handleAuthIntent(intent)
|
handleAuthIntent(intent)
|
||||||
|
handleShareIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAuthIntent catches the nikogpt://auth?code=… deep link from the
|
// isAuthUrl matches both the nikogpt:// scheme and the verified App Link.
|
||||||
// browser and completes the sign-in inside the app's WebView.
|
private fun isAuthUrl(uri: Uri): Boolean {
|
||||||
|
if (uri.scheme == "nikogpt" && uri.host == "auth") return true
|
||||||
|
return uri.scheme == "https" && uri.host == HOST && uri.path == "/app-auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
// openCamera writes the shot into the cache and hands it to the web app.
|
||||||
|
private fun openCamera() {
|
||||||
|
try {
|
||||||
|
val dir = File(cacheDir, "camera").apply { mkdirs() }
|
||||||
|
val file = File(dir, "photo_" + System.currentTimeMillis() + ".jpg")
|
||||||
|
val uri = FileProvider.getUriForFile(this, "dev.n1ko.nikogpt.files", file)
|
||||||
|
pendingCameraUri = uri
|
||||||
|
takePicture.launch(uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
fileCallback?.onReceiveValue(null)
|
||||||
|
fileCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleShareIntent catches "Share to NikoGPT" from other apps.
|
||||||
|
private fun handleShareIntent(intent: Intent?) {
|
||||||
|
if (intent == null || intent.action != Intent.ACTION_SEND) return
|
||||||
|
if (intent.type?.startsWith("text/") != true) return
|
||||||
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
pendingShare = text
|
||||||
|
maybeDeliverShare()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeDeliverShare fills the composer once the page is ready.
|
||||||
|
private fun maybeDeliverShare() {
|
||||||
|
val text = pendingShare ?: return
|
||||||
|
if (webView.url == null) return
|
||||||
|
pendingShare = null
|
||||||
|
webView.evaluateJavascript("window.nikoShare && window.nikoShare(" + org.json.JSONObject.quote(text) + ")", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthIntent catches the auth deep link (nikogpt:// or App Link) and
|
||||||
|
// completes the sign-in inside the app's WebView.
|
||||||
private fun handleAuthIntent(intent: Intent?) {
|
private fun handleAuthIntent(intent: Intent?) {
|
||||||
val data = intent?.data ?: return
|
val data = intent?.data ?: return
|
||||||
if (data.scheme == "nikogpt" && data.host == "auth") {
|
if (isAuthUrl(data)) {
|
||||||
handleAuthUri(data)
|
handleAuthUri(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,6 +360,13 @@ class MainActivity : AppCompatActivity() {
|
||||||
fun linkGoogle(clientId: String) {
|
fun linkGoogle(clientId: String) {
|
||||||
runOnUiThread { openGooglePicker(clientId, link = true) }
|
runOnUiThread { openGooglePicker(clientId, link = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The web app reports the message list's scroll position, so pulling
|
||||||
|
// down only refreshes when the list really is at the top.
|
||||||
|
@JavascriptInterface
|
||||||
|
fun setAtTop(atTop: Boolean) {
|
||||||
|
webAtTop = atTop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// openGooglePicker tries the native pickers in order: Credential Manager
|
// openGooglePicker tries the native pickers in order: Credential Manager
|
||||||
|
|
@ -325,6 +454,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
handleAuthIntent(intent)
|
handleAuthIntent(intent)
|
||||||
|
handleShareIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openExternally(uri: Uri) {
|
private fun openExternally(uri: Uri) {
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,8 @@
|
||||||
<string name="download_started">Загрузка началась</string>
|
<string name="download_started">Загрузка началась</string>
|
||||||
<string name="download_failed">Не удалось начать загрузку</string>
|
<string name="download_failed">Не удалось начать загрузку</string>
|
||||||
<string name="google_error">Не удалось войти через Google (%1$s)</string>
|
<string name="google_error">Не удалось войти через Google (%1$s)</string>
|
||||||
|
<string name="google_error_code">Не удалось войти через Google (код %1$d)</string>
|
||||||
|
<string name="google_no_token">Google не вернул ID-токен (проверьте Android OAuth-клиент: package и SHA-1)</string>
|
||||||
|
<string name="attach_file">Файл</string>
|
||||||
|
<string name="attach_camera">Камера</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,8 @@
|
||||||
<string name="download_started">Download started</string>
|
<string name="download_started">Download started</string>
|
||||||
<string name="download_failed">Could not start the download</string>
|
<string name="download_failed">Could not start the download</string>
|
||||||
<string name="google_error">Google sign-in failed (%1$s)</string>
|
<string name="google_error">Google sign-in failed (%1$s)</string>
|
||||||
|
<string name="google_error_code">Google sign-in failed (code %1$d)</string>
|
||||||
|
<string name="google_no_token">Google did not return an ID token (check the Android OAuth client, package and SHA-1)</string>
|
||||||
|
<string name="attach_file">File</string>
|
||||||
|
<string name="attach_camera">Camera</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="camera" path="camera/" />
|
||||||
|
</paths>
|
||||||
Loading…
Add table
Reference in a new issue