Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad92e93660 | ||
|
|
bcebc1dbcf | ||
|
|
07383624fc |
5 changed files with 69 additions and 6 deletions
|
|
@ -22,8 +22,8 @@ android {
|
||||||
applicationId = "dev.n1ko.nikogpt"
|
applicationId = "dev.n1ko.nikogpt"
|
||||||
minSdk = 29 // Android 10
|
minSdk = 29 // Android 10
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 8
|
||||||
versionName = "1.1.0"
|
versionName = "1.2.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +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.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
|
||||||
|
|
@ -14,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
|
||||||
|
|
@ -32,6 +35,7 @@ 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.content.FileProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
@ -76,6 +80,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
// Shared text ("Share to NikoGPT") waiting for the page to load.
|
// Shared text ("Share to NikoGPT") waiting for the page to load.
|
||||||
private var pendingShare: String? = null
|
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.
|
// Where the camera writes the picture before it reaches the web app.
|
||||||
private var pendingCameraUri: Uri? = null
|
private var pendingCameraUri: Uri? = null
|
||||||
|
|
||||||
|
|
@ -91,16 +99,38 @@ 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.
|
// Camera capture for the attach button.
|
||||||
|
|
@ -195,6 +225,22 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -234,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() {
|
||||||
|
|
@ -310,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
|
||||||
|
|
|
||||||
|
|
@ -7,6 +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_file">Файл</string>
|
||||||
<string name="attach_camera">Камера</string>
|
<string name="attach_camera">Камера</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +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_file">File</string>
|
||||||
<string name="attach_camera">Camera</string>
|
<string name="attach_camera">Camera</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue