Newer
Older
navi-1 / android-client / app / src / main / java / com / navi / client / MainActivity.kt
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)
    }
}