Compare commits

...

10 commits

8 changed files with 372 additions and 23 deletions

View file

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

View file

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application
android:allowBackup="true"

View file

@ -1,24 +1,56 @@
package com.duckai.app.web
import android.Manifest
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.view.inputmethod.InputMethodManager
import android.webkit.CookieManager
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.DownloadListener
import android.widget.Toast
import android.os.Environment
import android.app.DownloadManager
import android.provider.Settings
import java.io.File as IoFile
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.duckai.app.R
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var pendingFileCallback: ValueCallback<Array<Uri>>? = null
companion object {
private const val BASE_URL = "https://duck.ai"
}
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
pendingFileCallback?.onReceiveValue(uris.toTypedArray())
pendingFileCallback = null
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
openFilePicker()
} else {
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@ -37,6 +69,7 @@ class MainActivity : AppCompatActivity() {
setSupportZoom(false)
loadWithOverviewMode = true
useWideViewPort = true
setAllowFileAccessFromFileURLs(false)
}
webView.isFocusable = true
@ -45,18 +78,264 @@ class MainActivity : AppCompatActivity() {
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun downloadImage(url: String) {
runOnUiThread {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return@runOnUiThread
}
val fileName = android.webkit.URLUtil.guessFileName(url, null, null) ?: "image_${System.currentTimeMillis()}.png"
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val downloadRequest = DownloadManager.Request(Uri.parse(url)).apply {
setMimeType("image/png")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
}
downloadManager.enqueue(downloadRequest)
Toast.makeText(this@MainActivity, "Downloading...", Toast.LENGTH_SHORT).show()
}
}
@android.webkit.JavascriptInterface
fun downloadBase64Image(base64Data: String) {
runOnUiThread {
try {
val base64 = base64Data.substringAfter("base64,")
val imageBytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
val fileName = "duckai_image_${System.currentTimeMillis()}.jpg"
val contentValues = android.content.ContentValues().apply {
put(android.provider.MediaStore.Downloads.DISPLAY_NAME, fileName)
put(android.provider.MediaStore.Downloads.MIME_TYPE, "image/jpeg")
put(android.provider.MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = contentResolver
val uri = resolver.insert(android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
resolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(imageBytes)
}
contentValues.clear()
contentValues.put(android.provider.MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
Toast.makeText(this@MainActivity, "Файл сохранён в папке «Загрузки» (Downloads)\n$fileName", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(this@MainActivity, "Download failed", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@MainActivity, "Ошибка сохранения: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}, "Android")
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(message: String?, lineNumber: Int, sourceID: String?) {
message?.let { android.util.Log.d("DuckAI", it) }
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
pendingFileCallback = filePathCallback
checkPermissionAndOpenPicker()
return true
}
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.postDelayed({
view.evaluateJavascript(
"setTimeout(() => {" +
" const input = document.querySelector('input[type=\"text\"], textarea[id*=\"message\"], [role=\"combobox\"]');" +
" if(input) { input.focus(); input.click(); }" +
"}, 100);"
) { _ -> }
}, 800)
view?.postDelayed({ tryFocusInput(view) }, 2000)
view?.postDelayed({ injectDownloadHandler(view) }, 1500)
}
}
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength ->
if (url.startsWith("blob:") || url.startsWith("data:")) {
return@setDownloadListener
} else if (!url.startsWith("http://") && !url.startsWith("https://")) {
return@setDownloadListener
} else {
val fileName = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType) ?: "download"
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val downloadRequest = DownloadManager.Request(Uri.parse(url)).apply {
setMimeType(mimeType)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
}
downloadManager.enqueue(downloadRequest)
Toast.makeText(this, "Downloading...", Toast.LENGTH_SHORT).show()
}
}
}
private fun tryFocusInput(view: WebView?) {
view?.evaluateJavascript(
"(function() {" +
" const selectors = [" +
" 'textarea[placeholder=\"Ask privately\"]'," +
" 'textarea[name=\"user-prompt\"]'," +
" 'textarea.JRDRiEf5NPKWK43sArdC'," +
" 'textarea[id*=\"message\"]'," +
" 'textarea[name*=\"message\"]'," +
" 'div[contenteditable=\"true\"]'," +
" 'div[role=\"textbox\"]'" +
" ];" +
" " +
" for (const sel of selectors) {" +
" const el = document.querySelector(sel);" +
" if (el && el.offsetParent !== null) {" +
" el.style.visibility = 'visible';" +
" el.style.display = 'block';" +
" el.scrollIntoView({block: 'center', behavior: 'instant'});" +
" el.focus();" +
" if (el.tagName === 'TEXTAREA' && el.setSelectionRange) {" +
" el.setSelectionRange(el.value?.length || 0, el.value?.length || 0);" +
" }" +
" return 'FOUND: ' + sel;" +
" }" +
" }" +
" " +
" const allTextareas = document.querySelectorAll('textarea');" +
" for (const el of allTextareas) {" +
" if (el.offsetParent !== null && el.clientHeight > 20) {" +
" el.style.visibility = 'visible';" +
" el.style.display = 'block';" +
" el.scrollIntoView({block: 'center', behavior: 'instant'});" +
" el.focus();" +
" return 'FOUND fallback: ' + el.name + ' class=' + el.className;" +
" }" +
" }" +
" " +
" return 'NOT FOUND';" +
"})();"
) { result ->
if (result?.contains("NOT FOUND") == true) {
view?.postDelayed({ tryFocusInput(view) }, 1000)
}
}
}
private fun injectDownloadHandler(view: WebView?) {
view?.evaluateJavascript(
"(function() {" +
" if (window.downloadHandlerInjected) return;" +
" window.downloadHandlerInjected = true;" +
" " +
" function downloadBlobImage(src) {" +
" fetch(src)" +
" .then(res => res.blob())" +
" .then(blob => {" +
" const reader = new FileReader();" +
" reader.onloadend = function() {" +
" const base64 = reader.result.split(',')[1];" +
" const link = document.createElement('a');" +
" link.href = 'data:image/png;base64,' + base64;" +
" link.download = 'duckai_image_' + Date.now() + '.png';" +
" link.click();" +
" };" +
" reader.readAsDataURL(blob);" +
" })" +
" }" +
" " +
" function findImageSrc(btn) {" +
" let el = btn;" +
" for (let i = 0; i < 5 && el; i++) {" +
" const img = el.querySelector('img');" +
" if (img && img.src) return img.src;" +
" const sources = el.querySelectorAll('source');" +
" for (let s of sources) {" +
" if (s.src && s.src.startsWith('blob:')) return s.src;" +
" }" +
" el = el.parentElement;" +
" }" +
" const allImgs = document.querySelectorAll('img');" +
" for (let img of allImgs) {" +
" if (img.src.startsWith('blob:')) return img.src;" +
" }" +
" return null;" +
" }" +
" " +
" document.body.addEventListener('click', function(e) {" +
" const target = e.target;" +
" const btn = target.closest('button');" +
" if (!btn) return;" +
" " +
" const svg = btn.querySelector('svg');" +
" if (!svg) return;" +
" " +
" const path = svg.querySelector('path');" +
" if (!path || !path.getAttribute('d')) return;" +
" " +
" const d = path.getAttribute('d');" +
" " +
" if (d.includes('M8 .5') || d.includes('8.264')) {" +
" e.preventDefault();" +
" e.stopPropagation();" +
" " +
" const imgSrc = findImageSrc(btn);" +
" if (!imgSrc) return;" +
" " +
" if (imgSrc.startsWith('data:')) {" +
" window.Android && window.Android.downloadBase64Image && window.Android.downloadBase64Image(imgSrc);" +
" } else if (imgSrc.startsWith('blob:')) {" +
" downloadBlobImage(imgSrc);" +
" } else if (imgSrc.startsWith('http')) {" +
" window.Android && window.Android.downloadImage && window.Android.downloadImage(imgSrc);" +
" }" +
" }" +
" }, true);" +
"})();" , null)
}
private fun checkPermissionAndOpenPicker() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
openFilePicker()
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED) {
openFilePicker()
} else {
permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
private fun openFilePicker() {
val mimeTypes = arrayOf(
"text/plain",
"text/markdown",
"text/csv",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/rtf",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"application/x-python",
"application/javascript",
"application/java",
"application/json",
"application/xml",
"text/yaml",
"text/html",
"text/css",
"application/zip"
)
filePickerLauncher.launch(mimeTypes)
}
private fun loadUrlFromIntent() {
@ -70,9 +349,23 @@ class MainActivity : AppCompatActivity() {
webView.loadUrl(url)
window.decorView.postDelayed({
requestFocusAndShowKeyboard()
}, 1200)
webView.postDelayed({
injectDownloadHandler(webView)
}, 2000)
}
private fun requestFocusAndShowKeyboard() {
webView.requestFocus()
// Try to focus input field
tryFocusInput(webView)
// Show keyboard after JS
window.decorView.postDelayed({
showKeyboard()
}, 800)
}, 500)
}
private fun showKeyboard() {
@ -86,9 +379,20 @@ class MainActivity : AppCompatActivity() {
super.onPause()
}
override fun onResume() {
override fun onResume() {
webView.onResume()
super.onResume()
webView.postDelayed({
tryFocusInput(webView)
showKeyboard()
}, 500)
webView.postDelayed({
tryFocusInput(webView)
showKeyboard()
}, 1500)
webView.postDelayed({
injectDownloadHandler(webView)
}, 2000)
}
override fun onNewIntent(intent: Intent?) {
@ -100,9 +404,11 @@ class MainActivity : AppCompatActivity() {
val url = "$BASE_URL/?q=${Uri.encode(query)}"
webView.loadUrl(url)
webView.postDelayed({
webView.requestFocus()
showKeyboard()
}, 1000)
requestFocusAndShowKeyboard()
}, 1200)
webView.postDelayed({
injectDownloadHandler(webView)
}, 2000)
}
}

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

@ -5,13 +5,18 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 L54,78 M30,54 L78,54"
android:strokeWidth="8"
android:strokeColor="#FFFFFF"/>
android:fillColor="#DE5833"
android:pathData="M54,0C24.18,0,0,24.18,0,54s24.18,54,54,54s54-24.18,54-54S83.82,0,54,0z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M54,40 A14,14 0 1,1 54,68 A14,14 0 1,1 54,40"
android:strokeWidth="4"
android:strokeColor="#FFFFFF"/>
android:pathData="M54,27c-14.91,0-27,12.09-27,27s12.09,27,27,27s27-12.09,27-27S68.91,27,54,27z"/>
<path
android:fillColor="#1A1A2E"
android:pathData="M54,35c-10.55,0-19,8.45-19,19s8.45,19,19,19s19-8.45,19-19S64.55,35,54,35z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M48,42c0,3.31-2.69,6-6,6s-6-2.69-6-6s2.69-6,6-6S48,38.69,48,42z"/>
<path
android:fillColor="#FF9F1C"
android:pathData="M45,51l18,5l-18,5z"/>
</vector>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DuckAI" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">#D0BCFF</item>
<item name="colorOnPrimary">#381E72</item>
<item name="colorPrimaryContainer">#4F378B</item>
<item name="colorOnPrimaryContainer">#EADDFF</item>
<item name="colorSecondary">#CCC2DC</item>
<item name="colorOnSecondary">#332D41</item>
<item name="colorSecondaryContainer">#4A4458</item>
<item name="colorOnSecondaryContainer">#E8DEF8</item>
<item name="colorTertiary">#EFB8C8</item>
<item name="colorOnTertiary">#492532</item>
<item name="colorTertiaryContainer">#633B48</item>
<item name="colorOnTertiaryContainer">#FFD8E4</item>
<item name="colorError">#F2B8B5</item>
<item name="colorOnError">#601410</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#6750A4</color>
<color name="ic_launcher_background">#1A1A2E</color>
</resources>

View file

@ -9,4 +9,8 @@
<string name="yes">Yes</string>
<string name="no">No</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>