package com.navi.client
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
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.os.Message
import android.provider.MediaStore
import android.view.KeyEvent
import android.webkit.*
import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var serverHost: String = ""
private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
private var cameraImageUri: Uri? = null
private var pendingFileChooserCallback: ValueCallback<Array<Uri>>? = null
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { _ ->
pendingFileChooserCallback?.let { cb ->
pendingFileChooserCallback = null
openFileChooser(cb)
}
}
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val uris: Array<Uri>? = if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
when {
data == null || (data.data == null && data.clipData == null) ->
cameraImageUri?.let { arrayOf(it) }
data.clipData != null -> {
val clip = data.clipData!!
Array(clip.itemCount) { clip.getItemAt(it).uri }
}
else -> data.data?.let { arrayOf(it) }
}
} else null
fileChooserCallback?.onReceiveValue(uris)
fileChooserCallback = null
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("navi", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", null)
// Handle deep link from OAuth bridge page before normal setup.
intent?.data?.let { uri ->
if (uri.scheme == "navi" && uri.host == "auth" && uri.path == "/callback") {
val sid = uri.getQueryParameter("sid")
if (sid != null && serverUrl != null) {
CookieManager.getInstance().setCookie(
serverUrl,
"navi_auth_session=$sid; Path=/"
)
}
}
}
if (serverUrl.isNullOrBlank()) {
startActivity(Intent(this, SetupActivity::class.java))
finish()
return
}
serverHost = Uri.parse(serverUrl).host ?: ""
setContentView(R.layout.activity_main)
WebView.setWebContentsDebuggingEnabled(true)
webView = findViewById(R.id.webview)
setupWebView(prefs)
webView.loadUrl(serverUrl)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { uri ->
if (uri.scheme == "navi" && uri.host == "auth" && uri.path == "/callback") {
val sid = uri.getQueryParameter("sid")
val prefs = getSharedPreferences("navi", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", null)
if (sid != null && serverUrl != null) {
CookieManager.getInstance().setCookie(
serverUrl,
"navi_auth_session=$sid; Path=/"
)
webView.loadUrl(serverUrl)
}
}
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView(prefs: android.content.SharedPreferences) {
webView.settings.apply {
javaScriptEnabled = true
javaScriptCanOpenWindowsAutomatically = true
domStorageEnabled = true
cacheMode = WebSettings.LOAD_NO_CACHE
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
mediaPlaybackRequiresUserGesture = false
allowFileAccess = true
allowContentAccess = true
setSupportMultipleWindows(true)
userAgentString = "$userAgentString NaviAndroid/1.0"
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return if (shouldOpenExternally(request)) openExternal(request.url) else false
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
if (request.isForMainFrame) {
// Clear saved URL so next launch shows the setup screen
prefs.edit().remove("server_url").apply()
view.loadData(errorPage(error.description.toString()), "text/html", "utf-8")
}
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onCreateWindow(
view: WebView,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: Message
): Boolean {
if (!isUserGesture) return false
val popup = WebView(this@MainActivity)
popup.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
openExternal(request.url)
view.destroy()
return true
}
}
val transport = resultMsg.obj as WebView.WebViewTransport
transport.webView = popup
resultMsg.sendToTarget()
return true
}
override fun onShowFileChooser(
view: WebView,
callback: ValueCallback<Array<Uri>>,
params: FileChooserParams
): Boolean {
fileChooserCallback?.onReceiveValue(null)
fileChooserCallback = callback
val needed = mutableListOf<String>().apply {
if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) add(Manifest.permission.CAMERA)
val storage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Manifest.permission.READ_MEDIA_IMAGES
else
Manifest.permission.READ_EXTERNAL_STORAGE
if (ContextCompat.checkSelfPermission(this@MainActivity, storage)
!= PackageManager.PERMISSION_GRANTED) add(storage)
}
if (needed.isNotEmpty()) {
pendingFileChooserCallback = callback
fileChooserCallback = null
permissionLauncher.launch(needed.toTypedArray())
} else {
openFileChooser(callback)
}
return true
}
}
}
private fun shouldOpenExternally(request: WebResourceRequest): Boolean {
val uri = request.url
val scheme = uri.scheme?.lowercase(Locale.US) ?: return false
if (scheme != "http" && scheme != "https") {
return request.hasGesture()
}
val host = uri.host ?: return false
if (host != serverHost) return true
if (!request.hasGesture()) return false
val path = uri.encodedPath ?: return false
if (path.startsWith("/admin")) return true
if (path.startsWith("/content-viewers/")) return true
if (Regex("^/sessions/[^/]+/files/[^/]+").containsMatchIn(path)) return true
return uri.getQueryParameter("download") == "1"
}
private fun openExternal(uri: Uri): Boolean {
return try {
startActivity(
Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
}
)
true
} catch (e: Exception) {
false
}
}
private fun openFileChooser(callback: ValueCallback<Array<Uri>>) {
fileChooserCallback = callback
val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).also {
val photoFile = createTempImageFile()
cameraImageUri = FileProvider.getUriForFile(this, "$packageName.fileprovider", photoFile)
it.putExtra(MediaStore.EXTRA_OUTPUT, cameraImageUri)
}
val galleryIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*", "application/pdf", "*/*"))
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
filePickerLauncher.launch(
Intent.createChooser(galleryIntent, getString(R.string.chooser_title)).apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent))
}
)
}
private fun createTempImageFile(): File {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val dir = getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile("IMG_${timestamp}_", ".jpg", dir)
}
private fun errorPage(message: String) = """
<html><body style="background:#111;color:#888;font-family:sans-serif;padding:40px;text-align:center">
<h2 style="color:#555">Не удалось подключиться</h2>
<p>$message</p>
<p style="font-size:12px">При следующем запуске будет запрошен адрес сервера</p>
</body></html>
""".trimIndent()
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) {
webView.goBack()
return true
}
return super.onKeyDown(keyCode, event)
}
}