Add file attachment functionality with permission handling

This commit is contained in:
Alex Abudaev 2026-04-05 16:18:21 +08:00
parent fbd68b1c1c
commit 8637603930
5 changed files with 113 additions and 2 deletions

View file

@ -9,7 +9,7 @@ android {
defaultConfig { defaultConfig {
applicationId "com.duckai.app" applicationId "com.duckai.app"
minSdk 31 minSdk 33
targetSdk 34 targetSdk 34
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
@ -35,5 +35,6 @@ android {
dependencies { dependencies {
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'com.google.android.material:material:1.11.0' implementation 'com.google.android.material:material:1.11.0'
} }

View file

@ -1,31 +1,67 @@
package com.duckai.app.web package com.duckai.app.web
import android.Manifest
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.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.duckai.app.R import com.duckai.app.R
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView private lateinit var webView: WebView
private lateinit var attachButton: View
private var filePathCallback: ValueCallback<Array<Uri>>? = null
companion object { companion object {
private const val BASE_URL = "https://duck.ai" private const val BASE_URL = "https://duck.ai"
} }
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
if (uris.isNotEmpty()) {
filePathCallback?.onReceiveValue(uris.toTypedArray())
Toast.makeText(this, getString(R.string.file_attached), Toast.LENGTH_SHORT).show()
} else {
filePathCallback?.onReceiveValue(null)
}
filePathCallback = null
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
if (allGranted) {
openFilePicker()
} else {
Toast.makeText(this, getString(R.string.permission_required), Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
webView = findViewById(R.id.webView) webView = findViewById(R.id.webView)
attachButton = findViewById(R.id.attachButton)
setupWebView() setupWebView()
setupAttachButton()
loadUrlFromIntent() loadUrlFromIntent()
} }
@ -45,12 +81,24 @@ class MainActivity : AppCompatActivity() {
CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
webView.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
this@MainActivity.filePathCallback = filePathCallback
checkPermissionAndPickFile()
return true
}
}
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
view?.postDelayed({ view?.postDelayed({
view.evaluateJavascript( view.evaluateJavascript(
"setTimeout(() => {" + "setTimeout(() => {" +
" const input = document.querySelector('input[type=\"text\"], textarea[id*=\"message\"], [role=\"combobox\"]');" + " const input = document.querySelector('input[type=\"text\"], textarea[id*=\"message\"], [role=\"combobox\"], input[type=\"file\"]');" +
" if(input) { input.focus(); input.click(); }" + " if(input) { input.focus(); input.click(); }" +
"}, 100);" "}, 100);"
) { _ -> } ) { _ -> }
@ -59,6 +107,41 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun setupAttachButton() {
attachButton.setOnClickListener {
checkPermissionAndPickFile()
}
}
private fun checkPermissionAndPickFile() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissions = arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_AUDIO
)
val notGranted = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (notGranted.isEmpty()) {
openFilePicker()
} else {
permissionLauncher.launch(notGranted.toTypedArray())
}
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED) {
openFilePicker()
} else {
permissionLauncher.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE))
}
}
}
private fun openFilePicker() {
filePickerLauncher.launch(arrayOf("image/*", "video/*", "audio/*", "application/pdf"))
}
private fun loadUrlFromIntent() { private fun loadUrlFromIntent() {
val query = intent?.data?.getQueryParameter("q") val query = intent?.data?.getQueryParameter("q")
val url = if (query != null) { val url = if (query != null) {

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z"/>
</vector>

View file

@ -12,4 +12,17 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/attachButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:layout_marginBottom="80dp"
android:contentDescription="@string/attach_file"
android:src="@drawable/ic_attach"
app:fabSize="mini"
app:tint="?attr/colorOnPrimaryContainer"
app:backgroundTint="?attr/colorPrimaryContainer" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -9,4 +9,8 @@
<string name="yes">Yes</string> <string name="yes">Yes</string>
<string name="no">No</string> <string name="no">No</string>
<string name="history_cleared">Chat history cleared</string> <string name="history_cleared">Chat history cleared</string>
<string name="attach_file">Attach file</string>
<string name="file_attached">File attached</string>
<string name="no_app_for_file">No app found to open this file</string>
<string name="permission_required">Storage permission required</string>
</resources> </resources>