Compare commits

..

5 commits
v1.0.3 ... main

Author SHA1 Message Date
Niko Marmeladkov
ad92e93660 1.2.2: fix accidental pull-to-refresh; settings tabs on phones
Some checks are pending
build / apk (push) Waiting to run
- the web app reports the message list's scroll position through the bridge,
  so pull-to-refresh only fires when the list is really at the top; the
  trigger distance is bigger (the WebView itself never scrolls)
- settings tabs scroll horizontally on narrow screens (all tabs reachable)
- version 1.2.2
2026-10-05 21:27:01 +03:00
Niko Marmeladkov
bcebc1dbcf 1.2.1: clearer Google sign-in failures on microG
Some checks are pending
build / apk (push) Waiting to run
- show the ApiException status code (e.g. DEVELOPER_ERROR 10) instead of
  silently falling back to the browser
- do not leave the app when the picker is cancelled (12501/16)
- report a missing ID token explicitly (Android OAuth client / SHA-1 hint)
- version 1.2.1
2026-10-05 21:23:18 +03:00
Niko Marmeladkov
07383624fc 1.2.0: voice input in the web chat
Some checks failed
build / apk (push) Has been cancelled
- RECORD_AUDIO permission granted to the WebView (onPermissionRequest)
- the chat's single composer button becomes a microphone when the input is
  empty and voice input is available; recording stops into server-side ASR
  (/api/transcribe) when the browser has no Web Speech API
- version 1.2.0
2026-10-05 20:33:26 +03:00
Niko Marmeladkov
898bcb792b 1.1.0: release signing, App Links, camera and share, update check
Some checks failed
build / apk (push) Has been cancelled
- signed release builds via keystore.properties (gitignored)
- Android App Links (https://chat.n1ko.dev/app-auth) verified through
  /.well-known/assetlinks.json; nikogpt:// stays as fallback
- attach sheet with File/Camera (FileProvider)
- Share to NikoGPT (ACTION_SEND text)
- user agent carries the app version so the web UI can offer updates
2026-10-05 18:14:21 +03:00
Niko Marmeladkov
b2914f685a Google sign-in fallback chain: Credential Manager -> legacy API (microG) -> browser
Some checks failed
build / apk (push) Has been cancelled
- try Credential Manager first; on any failure fall back to the legacy
  GoogleSignIn API, which microG implements
- only when no Google provider is available (isGooglePlayServicesAvailable
  fails) use the browser flow with the nikogpt:// hand-back
- README: document the order and the Android OAuth client requirements
- version 1.0.4
2026-10-05 17:31:30 +03:00
8 changed files with 294 additions and 29 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
.gradle/ .gradle/
build/ build/
local.properties local.properties
keystore.properties
*.iml *.iml
.idea/ .idea/
.DS_Store .DS_Store

View file

@ -41,16 +41,44 @@ 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
Native "Continue with Google" is attempted in this order:
1. **Credential Manager** (modern GMS) — the standard account sheet;
2. **legacy Google Sign-In API** — this is what **microG** implements, so the
native picker works on de-Googled ROMs with microG and a Google account
added there;
3. **browser flow** — used when neither provider is available (no GMS, no
microG, or cancelled); the browser returns to the app through the
`nikogpt://auth` deep link and the session lands in the WebView.
Native sign-in needs an **Android-type OAuth client** in the same Google Cloud
project, with the app's package name (`dev.n1ko.nikogpt`) and the SHA-1 of the
signing certificate. The web client id (from the server) is passed as
`serverClientId`.
## Structure ## Structure
| Path | Purpose | | Path | Purpose |

View file

@ -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 = 3 versionCode = 8
versionName = "1.0.3" 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")
}
} }
} }
@ -51,4 +76,7 @@ dependencies {
implementation("androidx.credentials:credentials:1.3.0") implementation("androidx.credentials:credentials:1.3.0")
implementation("androidx.credentials:credentials-play-services-auth:1.3.0") implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0") implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0")
// Legacy Google Sign-In: microG implements this API, Credential Manager is
// GMS-only. Both are tried before the browser fallback.
implementation("com.google.android.gms:play-services-auth:21.2.0")
} }

View file

@ -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>

View file

@ -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
@ -40,8 +46,14 @@ import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialException
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
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
/** /**
@ -56,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
@ -65,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 ->
@ -72,6 +94,55 @@ class MainActivity : AppCompatActivity() {
fileCallback = null fileCallback = null
} }
// microG path: the legacy Google Sign-In API, which microG implements.
private var legacyLinkMode = false
private val legacySignIn: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val link = legacyLinkMode
val account = try {
GoogleSignIn.getSignedInAccountFromIntent(result.data)
.getResult(ApiException::class.java)
} 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)
return@registerForActivityResult
} catch (e: Exception) {
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")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -120,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
@ -149,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
@ -165,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
} }
} }
@ -180,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() {
@ -193,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)
} }
} }
@ -216,14 +360,19 @@ 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 requests an ID token from Google Play services // openGooglePicker tries the native pickers in order: Credential Manager
// (Credential Manager). Without a usable provider — e.g. microG, no // (modern GMS) → legacy Google Sign-In (microG) → browser flow.
// accounts, user cancelled — it falls back to the browser flow with the
// nikogpt:// hand-back.
private fun openGooglePicker(clientId: String, link: Boolean) { private fun openGooglePicker(clientId: String, link: Boolean) {
if (clientId.isBlank()) { if (clientId.isBlank() || !googleServicesAvailable()) {
openGoogleInBrowser(link) openGoogleInBrowser(link)
return return
} }
@ -241,14 +390,37 @@ class MainActivity : AppCompatActivity() {
.getCredential(this@MainActivity, request) .getCredential(this@MainActivity, request)
val idToken = GoogleIdTokenCredential.createFrom(response.credential.data).idToken val idToken = GoogleIdTokenCredential.createFrom(response.credential.data).idToken
deliverToken(idToken, link) deliverToken(idToken, link)
} catch (e: GetCredentialException) {
openGoogleInBrowser(link)
} catch (e: Exception) { } catch (e: Exception) {
openGoogleInBrowser(link) // No Credential Manager provider (microG) — try the legacy API.
openLegacyGooglePicker(clientId, link)
} }
} }
} }
// googleServicesAvailable is true for GMS and for microG (which reports
// itself as Play services when signature spoofing is enabled).
private fun googleServicesAvailable(): Boolean {
return try {
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
@Deprecated("microG only implements the legacy Google Sign-In API")
private fun openLegacyGooglePicker(clientId: String, link: Boolean) {
legacyLinkMode = link
try {
val options = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(clientId)
.requestEmail()
.build()
legacySignIn.launch(GoogleSignIn.getClient(this, options).signInIntent)
} catch (e: Exception) {
openGoogleInBrowser(link)
}
}
// deliverToken hands the ID token back to the page, which signs in // deliverToken hands the ID token back to the page, which signs in
// through the API inside this WebView (so the cookie stays in the app). // through the API inside this WebView (so the cookie stays in the app).
private fun deliverToken(idToken: String, link: Boolean) { private fun deliverToken(idToken: String, link: Boolean) {
@ -282,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) {

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera" path="camera/" />
</paths>