diff --git a/app/build.gradle b/app/build.gradle index 1631f76..5066320 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f384ed3..88115fc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + >? = 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 @@ -44,19 +77,265 @@ 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>?, + 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({ - webView.requestFocus() + 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) } } diff --git a/app/src/main/res/drawable/ic_attach.xml b/app/src/main/res/drawable/ic_attach.xml new file mode 100644 index 0000000..7c9d932 --- /dev/null +++ b/app/src/main/res/drawable/ic_attach.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index b7bb7df..5859fe5 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -5,13 +5,18 @@ android:viewportWidth="108" android:viewportHeight="108"> + 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"/> + android:pathData="M54,27c-14.91,0-27,12.09-27,27s12.09,27,27,27s27-12.09,27-27S68.91,27,54,27z"/> + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..b9c1557 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 9b5e432..c197d89 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,4 +1,4 @@ - #6750A4 + #1A1A2E \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aba6527..253b724 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,4 +9,8 @@ Yes No Chat history cleared + Attach file + File attached + No app found to open this file + Storage permission required \ No newline at end of file