Compare commits

...

10 commits

8 changed files with 372 additions and 23 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

@ -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" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application <application
android:allowBackup="true" android:allowBackup="true"

View file

@ -1,24 +1,56 @@
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.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.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.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 var pendingFileCallback: 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 ->
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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@ -37,6 +69,7 @@ class MainActivity : AppCompatActivity() {
setSupportZoom(false) setSupportZoom(false)
loadWithOverviewMode = true loadWithOverviewMode = true
useWideViewPort = true useWideViewPort = true
setAllowFileAccessFromFileURLs(false)
} }
webView.isFocusable = true webView.isFocusable = true
@ -45,18 +78,264 @@ class MainActivity : AppCompatActivity() {
CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, 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() { webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
view?.postDelayed({ view?.postDelayed({ tryFocusInput(view) }, 2000)
view.evaluateJavascript( view?.postDelayed({ injectDownloadHandler(view) }, 1500)
"setTimeout(() => {" +
" const input = document.querySelector('input[type=\"text\"], textarea[id*=\"message\"], [role=\"combobox\"]');" +
" if(input) { input.focus(); input.click(); }" +
"}, 100);"
) { _ -> }
}, 800)
} }
} }
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() { private fun loadUrlFromIntent() {
@ -70,9 +349,23 @@ class MainActivity : AppCompatActivity() {
webView.loadUrl(url) webView.loadUrl(url)
window.decorView.postDelayed({ window.decorView.postDelayed({
requestFocusAndShowKeyboard()
}, 1200)
webView.postDelayed({
injectDownloadHandler(webView)
}, 2000)
}
private fun requestFocusAndShowKeyboard() {
webView.requestFocus() webView.requestFocus()
// Try to focus input field
tryFocusInput(webView)
// Show keyboard after JS
window.decorView.postDelayed({
showKeyboard() showKeyboard()
}, 800) }, 500)
} }
private fun showKeyboard() { private fun showKeyboard() {
@ -89,6 +382,17 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
webView.onResume() webView.onResume()
super.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?) { override fun onNewIntent(intent: Intent?) {
@ -100,9 +404,11 @@ class MainActivity : AppCompatActivity() {
val url = "$BASE_URL/?q=${Uri.encode(query)}" val url = "$BASE_URL/?q=${Uri.encode(query)}"
webView.loadUrl(url) webView.loadUrl(url)
webView.postDelayed({ webView.postDelayed({
webView.requestFocus() requestFocusAndShowKeyboard()
showKeyboard() }, 1200)
}, 1000) 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:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:fillColor="#FFFFFF" android:fillColor="#DE5833"
android:pathData="M54,30 L54,78 M30,54 L78,54" android:pathData="M54,0C24.18,0,0,24.18,0,54s24.18,54,54,54s54-24.18,54-54S83.82,0,54,0z"/>
android:strokeWidth="8"
android:strokeColor="#FFFFFF"/>
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:pathData="M54,40 A14,14 0 1,1 54,68 A14,14 0 1,1 54,40" android:pathData="M54,27c-14.91,0-27,12.09-27,27s12.09,27,27,27s27-12.09,27-27S68.91,27,54,27z"/>
android:strokeWidth="4" <path
android:strokeColor="#FFFFFF"/> 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> </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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#6750A4</color> <color name="ic_launcher_background">#1A1A2E</color>
</resources> </resources>

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>