diff --git a/duckchat/duckchat-impl/build.gradle b/duckchat/duckchat-impl/build.gradle index f539214a3e95..996f6fe9b563 100644 --- a/duckchat/duckchat-impl/build.gradle +++ b/duckchat/duckchat-impl/build.gradle @@ -46,6 +46,7 @@ dependencies { implementation AndroidX.core.ktx implementation Google.android.material implementation Google.dagger + implementation AndroidX.work.runtimeKtx implementation "com.squareup.logcat:logcat:_" diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index 255f4eb3fd08..764bc07f5280 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -33,6 +33,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams +import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository @@ -120,6 +121,11 @@ interface DuckChatInternal : DuckChat { * Returns the current chat state. */ val chatState: StateFlow + + /** + * Returns whether image upload is enabled or not. + */ + fun isImageUploadEnabled(): Boolean } enum class ChatState(val value: String) { @@ -162,6 +168,7 @@ class RealDuckChat @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val pixel: Pixel, private val experimentDataStore: VisualDesignExperimentDataStore, + private val imageUploadFeature: AIChatImageUploadFeature, ) : DuckChatInternal, PrivacyConfigCallbackPlugin { private val closeChatFlow = MutableSharedFlow(replay = 0) @@ -178,6 +185,7 @@ class RealDuckChat @Inject constructor( private var duckChatLink = DUCK_CHAT_WEB_LINK private var bangRegex: Regex? = null private var isAddressBarEntryPointEnabled: Boolean = false + private var isImageUploadEnabled: Boolean = false init { if (isMainProcess) { @@ -276,6 +284,8 @@ class RealDuckChat @Inject constructor( override val chatState: StateFlow get() = _chatState.asStateFlow() + override fun isImageUploadEnabled(): Boolean = isImageUploadEnabled + override fun openDuckChat(query: String?) { val parameters = query?.let { originalQuery -> val hasDuckChatBang = isDuckChatBang(originalQuery.toUri()) @@ -383,6 +393,7 @@ class RealDuckChat @Inject constructor( bangRegex = settingsJson.aiChatBangRegex?.replace("{bangs}", bangAlternation)?.toRegex() } isAddressBarEntryPointEnabled = settingsJson?.addressBarEntryPoint ?: false + isImageUploadEnabled = imageUploadFeature.self().isEnabled() cacheUserSettings() } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/AIChatImageUploadFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/AIChatImageUploadFeature.kt new file mode 100644 index 000000000000..607a476dec1a --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/AIChatImageUploadFeature.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.feature + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "aiChatImageUpload", +) + +interface AIChatImageUploadFeature { + + @Toggle.DefaultValue(DefaultFeatureValue.TRUE) + fun self(): Toggle +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt index dfcb25729679..6cf047eb9676 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt @@ -104,6 +104,7 @@ class RealDuckChatJSHelper @Inject constructor( put(SUPPORTS_CLOSING_AI_CHAT, true) put(SUPPORTS_OPENING_SETTINGS, true) put(SUPPORTS_NATIVE_CHAT_INPUT, experimentDataStore.isExperimentEnabled.value && experimentDataStore.isDuckAIPoCEnabled.value) + put(SUPPORTS_IMAGE_UPLOAD, duckChat.isImageUploadEnabled()) } return JsCallbackData(jsonPayload, featureName, method, id) } @@ -129,6 +130,7 @@ class RealDuckChatJSHelper @Inject constructor( private const val SUPPORTS_CLOSING_AI_CHAT = "supportsClosingAIChat" private const val SUPPORTS_OPENING_SETTINGS = "supportsOpeningSettings" private const val SUPPORTS_NATIVE_CHAT_INPUT = "supportsNativeChatInput" + private const val SUPPORTS_IMAGE_UPLOAD = "supportsImageUpload" private const val PLATFORM = "platform" private const val ANDROID = "android" } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt index f77dbd235756..d8c51fc82b0b 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt @@ -18,13 +18,19 @@ package com.duckduckgo.duckchat.impl.ui import android.Manifest import android.annotation.SuppressLint +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle import android.os.Environment import android.os.Message +import android.provider.MediaStore import android.view.MenuItem import android.view.ViewGroup +import android.webkit.MimeTypeMap +import android.webkit.ValueCallback import android.webkit.WebChromeClient +import android.webkit.WebChromeClient.FileChooserParams import android.webkit.WebSettings import android.webkit.WebView import androidx.annotation.AnyThread @@ -37,6 +43,7 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.view.dialog.ActionBottomSheetDialog import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider @@ -54,10 +61,18 @@ import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.feature.AIChatDownloadFeature import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper import com.duckduckgo.duckchat.impl.helper.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME +import com.duckduckgo.duckchat.impl.ui.filechooser.FileChooserIntentBuilder +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera.CameraHardwareChecker +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.CouldNotCapturePermissionDenied +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.ErrorAccessingMediaApp +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.MediaCaptured +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured import com.duckduckgo.js.messaging.api.JsMessageCallback import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.navigation.api.getActivityParams +import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import java.io.File import javax.inject.Inject @@ -114,9 +129,20 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD @Inject lateinit var aiChatDownloadFeature: AIChatDownloadFeature + @Inject + lateinit var fileChooserIntentBuilder: FileChooserIntentBuilder + + @Inject + lateinit var cameraHardwareChecker: CameraHardwareChecker + + @Inject + lateinit var externalCameraLauncher: UploadFromExternalMediaAppLauncher + private var pendingFileDownload: PendingFileDownload? = null private val downloadMessagesJob = ConflatedJob() + private var pendingUploadTask: ValueCallback>? = null + private val root: ViewGroup by lazy { findViewById(android.R.id.content) } private val toolbar: Toolbar? by lazy { findViewById(com.duckduckgo.mobile.android.R.id.toolbar) } internal val simpleWebview: WebView by lazy { findViewById(R.id.simpleWebview) } @@ -156,6 +182,21 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD } return false } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams, + ): Boolean { + return try { + showFileChooser(filePathCallback, fileChooserParams) + true + } catch (e: Throwable) { + // cancel the request using the documented way + filePathCallback.onReceiveValue(null) + throw e + } + } } it.settings.apply { @@ -209,6 +250,146 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD url?.let { simpleWebview.loadUrl(it) } + + externalCameraLauncher.registerForResult(this) { + when (it) { + is MediaCaptured -> pendingUploadTask?.onReceiveValue(arrayOf(Uri.fromFile(it.file))) + is CouldNotCapturePermissionDenied -> { + pendingUploadTask?.onReceiveValue(null) + externalCameraLauncher.showPermissionRationaleDialog(this, it.inputAction) + } + + is NoMediaCaptured -> pendingUploadTask?.onReceiveValue(null) + is ErrorAccessingMediaApp -> { + pendingUploadTask?.onReceiveValue(null) + Snackbar.make(root, it.messageId, BaseTransientBottomBar.LENGTH_SHORT).show() + } + } + pendingUploadTask = null + } + } + + data class FileChooserRequestedParams( + val filePickingMode: Int, + val acceptMimeTypes: List, + ) + + fun showFileChooser( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams, + ) { + val mimeTypes = convertAcceptTypesToMimeTypes(fileChooserParams.acceptTypes) + val fileChooserRequestedParams = FileChooserRequestedParams(fileChooserParams.mode, mimeTypes) + val cameraHardwareAvailable = cameraHardwareChecker.hasCameraHardware() + + when { + fileChooserParams.isCaptureEnabled -> { + when { + acceptsOnly("image/", fileChooserParams.acceptTypes) && cameraHardwareAvailable -> + launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE) + + acceptsOnly("video/", fileChooserParams.acceptTypes) && cameraHardwareAvailable -> + launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE) + + acceptsOnly("audio/", fileChooserParams.acceptTypes) -> + launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.Audio.Media.RECORD_SOUND_ACTION) + + else -> + launchFilePicker(filePathCallback, fileChooserRequestedParams) + } + } + + fileChooserParams.acceptTypes.any { it.startsWith("image/") && cameraHardwareAvailable } -> + launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE) + + fileChooserParams.acceptTypes.any { it.startsWith("video/") && cameraHardwareAvailable } -> + launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE) + + else -> + launchFilePicker(filePathCallback, fileChooserRequestedParams) + } + } + + private fun launchFilePicker( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserRequestedParams, + ) { + pendingUploadTask = filePathCallback + val canChooseMultipleFiles = fileChooserParams.filePickingMode == FileChooserParams.MODE_OPEN_MULTIPLE + val intent = fileChooserIntentBuilder.intent(fileChooserParams.acceptMimeTypes.toTypedArray(), canChooseMultipleFiles) + startActivityForResult(intent, REQUEST_CODE_CHOOSE_FILE) + } + + private fun launchCameraCapture( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserRequestedParams, + inputAction: String, + ) { + if (Intent(inputAction).resolveActivity(packageManager) == null) { + launchFilePicker(filePathCallback, fileChooserParams) + return + } + + pendingUploadTask = filePathCallback + externalCameraLauncher.launch(inputAction) + } + + private fun launchImageOrCameraChooser( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserRequestedParams, + inputAction: String, + ) { + val cameraString = getString(R.string.imageCaptureCameraGalleryDisambiguationCameraOption) + val cameraIcon = com.duckduckgo.mobile.android.R.drawable.ic_camera_24 + + val galleryString = getString(R.string.imageCaptureCameraGalleryDisambiguationGalleryOption) + val galleryIcon = com.duckduckgo.mobile.android.R.drawable.ic_image_24 + + ActionBottomSheetDialog.Builder(this) + .setTitle(getString(R.string.imageCaptureCameraGalleryDisambiguationTitle)) + .setPrimaryItem(galleryString, galleryIcon) + .setSecondaryItem(cameraString, cameraIcon) + .addEventListener( + object : ActionBottomSheetDialog.EventListener() { + override fun onPrimaryItemClicked() { + launchFilePicker(filePathCallback, fileChooserParams) + } + + override fun onSecondaryItemClicked() { + launchCameraCapture(filePathCallback, fileChooserParams, inputAction) + } + + override fun onBottomSheetDismissed() { + filePathCallback.onReceiveValue(null) + pendingUploadTask = null + } + }, + ) + .show() + } + + private fun acceptsOnly( + type: String, + acceptTypes: Array, + ): Boolean { + return acceptTypes.filter { it.startsWith(type) }.size == acceptTypes.size + } + + private fun convertAcceptTypesToMimeTypes(acceptTypes: Array): List { + val mimeTypeMap = MimeTypeMap.getSingleton() + val mimeTypes = mutableSetOf() + acceptTypes.forEach { type -> + // Attempt to convert any identified file extensions into corresponding MIME types. + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(type) + if (fileExtension.isNotEmpty()) { + mimeTypeMap.getMimeTypeFromExtension(type.substring(1))?.let { + mimeTypes.add(it) + } + } else { + mimeTypes.add(type) + } + } + return mimeTypes.toList() } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -322,6 +503,30 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE) } + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent?, + ) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_CODE_CHOOSE_FILE) { + handleFileUploadResult(resultCode, data) + } + } + + private fun handleFileUploadResult( + resultCode: Int, + intent: Intent?, + ) { + if (resultCode != RESULT_OK || intent == null) { + pendingUploadTask?.onReceiveValue(null) + return + } + + val uris = fileChooserIntentBuilder.extractSelectedFileUris(intent) + pendingUploadTask?.onReceiveValue(uris) + } + override fun onResume() { launchDownloadMessagesJob() super.onResume() @@ -336,5 +541,6 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200 private const val CUSTOM_UA = "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile DuckDuckGo/5 Safari/537.36" + private const val REQUEST_CODE_CHOOSE_FILE = 100 } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/FileChooserIntentBuilder.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/FileChooserIntentBuilder.kt new file mode 100644 index 000000000000..e8c5da051294 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/FileChooserIntentBuilder.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser + +import android.content.Intent +import android.net.Uri +import java.util.* +import javax.inject.Inject +import logcat.logcat + +class FileChooserIntentBuilder @Inject constructor() { + + fun intent( + acceptTypes: Array, + canChooseMultiple: Boolean = false, + ): Intent { + return Intent(Intent.ACTION_GET_CONTENT).also { + it.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + configureSelectableFileTypes(it, acceptTypes) + configureAllowMultipleFile(it, canChooseMultiple) + } + } + + /** + * Some apps return data data as `intent.data` value, some in the `intent.clipData`; some use both. + * + * If a user selects multiple files, then both the `data` and `clipData` might be populated, but we'd want to use `clipData`. + * If we inspect `data` first, we might conclude there is only a single file selected. So we look for `clipData` first. + * + * Empirically, the first value of `clipData` might mirror what is in the `data` value. So if we have any in `clipData`, use + * them and return early. + * + * Order is important; + * we want to use the clip data if it exists. + * failing that, we check `data` value`. + * failing that, we bail. + */ + fun extractSelectedFileUris(intent: Intent): Array? { + // first try to determine if multiple files were selected + val clipData = intent.clipData + if (clipData != null && clipData.itemCount > 0) { + val uris = arrayListOf() + for (i in 0 until clipData.itemCount) { + uris.add(clipData.getItemAt(i).uri) + } + return uris.toTypedArray() + } + + // next try to determine if a single file was selected + val singleFileResult = intent.data + if (singleFileResult != null) { + return arrayOf(singleFileResult) + } + + // failing that, give up + logcat { "Failed to extract selected file information" } + return null + } + + private fun configureSelectableFileTypes( + intent: Intent, + acceptTypes: Array, + ) { + intent.type = "*/*" + + val acceptedMimeTypes = mutableSetOf() + + acceptTypes + .filter { it.isNotBlank() } + .forEach { acceptedMimeTypes.add(it.lowercase(Locale.getDefault())) } + + if (acceptedMimeTypes.isNotEmpty()) { + logcat { "Selectable file types limited to $acceptedMimeTypes" } + intent.putExtra(Intent.EXTRA_MIME_TYPES, acceptedMimeTypes.toTypedArray()) + } else { + logcat { "No selectable file type filters applied" } + } + } + + private fun configureAllowMultipleFile( + intent: Intent, + canChooseMultiple: Boolean, + ) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, canChooseMultiple) + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/MediaCaptureResultHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/MediaCaptureResultHandler.kt new file mode 100644 index 000000000000..a337fc9e568a --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/MediaCaptureResultHandler.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser.capture + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.content.FileProvider +import java.io.File + +class MediaCaptureResultHandler : ActivityResultContract() { + + private var interimImageLocation: Uri? = null + + override fun createIntent( + context: Context, + input: String?, + ): Intent { + return when (input) { + MediaStore.ACTION_IMAGE_CAPTURE, MediaStore.ACTION_VIDEO_CAPTURE -> { + val destinationForCapturedMedia = + destinationMediaCaptureFile(context, input) ?: throw IllegalStateException("Unable to save images from camera") + Intent(input).also { intent -> + destinationForCapturedMedia.also { newFile -> + val safeUri = FileProvider.getUriForFile(context, "${context.packageName}.$PROVIDER_SUFFIX", newFile) + interimImageLocation = safeUri + intent.putExtra(MediaStore.EXTRA_OUTPUT, safeUri) + } + } + } + else -> Intent(input ?: MediaStore.ACTION_IMAGE_CAPTURE) + } + } + + private fun destinationMediaCaptureFile(context: Context, input: String?): File? { + val topLevelDirectory: File = context.externalCacheDir ?: return null + val dataDir = File(topLevelDirectory, SUBDIRECTORY) + dataDir.mkdirs() + val fileExtension = when (input) { + MediaStore.ACTION_IMAGE_CAPTURE -> IMAGE_FILE_EXTENSION + MediaStore.ACTION_VIDEO_CAPTURE -> VIDEO_FILE_EXTENSION + MediaStore.Audio.Media.RECORD_SOUND_ACTION -> AUDIO_FILE_EXTENSION + else -> "" + } + val newFileName = "${System.currentTimeMillis()}.$fileExtension" + return File(dataDir, newFileName) + } + + override fun parseResult( + resultCode: Int, + intent: Intent?, + ): Uri? { + if (resultCode != Activity.RESULT_OK) { + return null + } + + if (intent?.data == null && interimImageLocation == null) { + return null + } + + if (interimImageLocation == null) { + // at this point intent?.data is not null + val contentUri = intent?.data!! + interimImageLocation = contentUri + } + + return interimImageLocation + } + + companion object { + private const val SUBDIRECTORY = "browser-uploads" + private const val PROVIDER_SUFFIX = "provider" + private const val IMAGE_FILE_EXTENSION = "jpg" + private const val VIDEO_FILE_EXTENSION = "mp4" + private const val AUDIO_FILE_EXTENSION = "m4a" + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/camera/CameraHardwareChecker.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/camera/CameraHardwareChecker.kt new file mode 100644 index 000000000000..5b2c6ca8a294 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/camera/CameraHardwareChecker.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera + +import android.content.Context +import android.content.pm.PackageManager.FEATURE_CAMERA_ANY +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface CameraHardwareChecker { + fun hasCameraHardware(): Boolean +} + +@ContributesBinding(AppScope::class) +class CameraHardwareCheckerImpl @Inject constructor( + private val context: Context, +) : CameraHardwareChecker { + + override fun hasCameraHardware(): Boolean { + return with(context.packageManager) { + kotlin.runCatching { hasSystemFeature(FEATURE_CAMERA_ANY) }.getOrDefault(false) + } + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/launcher/UploadFromExternalMediaAppLauncher.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/launcher/UploadFromExternalMediaAppLauncher.kt new file mode 100644 index 000000000000..b3311130eeaa --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/launcher/UploadFromExternalMediaAppLauncher.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import android.provider.Settings +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.StringRes +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.impl.R +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.MediaCaptureResultHandler +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.permission.ExternalMediaSystemPermissionsHelper +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.postprocess.MediaCaptureDelayedDeleter +import com.duckduckgo.duckchat.impl.ui.filechooser.capture.postprocess.MediaCaptureImageMover +import com.squareup.anvil.annotations.ContributesBinding +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import logcat.asLog +import logcat.logcat + +/** + * Public API for launching any external media capturing app (e.g camera, sound recorder) and capturing. + * This launcher will internally handle necessary permissions. + * + * [registerForResult] should be called once in [Activity.onCreate] + * [launch] should be called when it is time to launch the media capturing app. + */ +interface UploadFromExternalMediaAppLauncher { + + /** + * Launches the external media capturing app with a given action. + * Before calling launch, you must register a callback to receive the result, using [registerForResult]. + * + * @param inputAction This is used to inform what type of media was requested. + */ + fun launch(inputAction: String) + + /** + * Registers a callback to receive the result of the capture. + * This must be called before calling [launch]. + * + * @param onResult will be called with the captured content or another result type if the capture failed. + */ + fun registerForResult( + caller: ActivityResultCaller, + onResult: (MediaCaptureResult) -> Unit, + ) + + /** + * Shows the permission rationale dialog. + * + * @param inputAction This is used to inform what type of media was requested. + */ + fun showPermissionRationaleDialog(activity: Activity, inputAction: String) + + /** + * Types of results that can be returned from the media capture flow (e.g camera, sound recorder). + */ + sealed interface MediaCaptureResult { + + /** + * The media was captured successfully. + * The included [file] is the location of the captured media. + * + * Note, this file should be considered temporary and will be automatically deleted after a short period of time. + */ + data class MediaCaptured(val file: File) : MediaCaptureResult + + /** + * The user denied permission. + * + * @param inputAction This is used to inform what type of media was requested. + */ + data class CouldNotCapturePermissionDenied(val inputAction: String) : MediaCaptureResult + + /** + * No media was captured, most likely because the user cancelled the capture flow. + */ + data object NoMediaCaptured : MediaCaptureResult + + /** + * No media was captured due to an error accessing the media app. + * + * @param messageId The message to be shown as error. + */ + data class ErrorAccessingMediaApp(@StringRes val messageId: Int) : MediaCaptureResult + } +} + +@ContributesBinding(ActivityScope::class) +class PermissionAwareExternalMediaAppLauncher @Inject constructor( + private val permissionHelper: ExternalMediaSystemPermissionsHelper, + private val imageMover: MediaCaptureImageMover, + private val delayedDeleter: MediaCaptureDelayedDeleter, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, +) : UploadFromExternalMediaAppLauncher { + + private lateinit var callback: (MediaCaptureResult) -> Unit + private lateinit var launcher: ActivityResultLauncher + + override fun launch(inputAction: String) { + if (permissionHelper.hasMediaPermissionsGranted(inputAction)) { + logcat { "permission already granted for $inputAction. launching app now" } + launchMediaApp(inputAction) + } else { + // ask for permission + logcat { "no permission yet for $inputAction, need to request permission before launching" } + when (inputAction) { + MediaStore.ACTION_IMAGE_CAPTURE, MediaStore.ACTION_VIDEO_CAPTURE -> + permissionHelper.requestPermission(Manifest.permission.CAMERA, inputAction) + MediaStore.Audio.Media.RECORD_SOUND_ACTION -> + permissionHelper.requestPermission(Manifest.permission.RECORD_AUDIO, inputAction) + else -> + logcat { "Unknown permissions needed for $inputAction" } + } + } + } + + private fun launchMediaApp(inputAction: String) { + try { + launcher.launch(inputAction) + } catch (e: Exception) { + logcat { "exception launching camera / sound recorder: ${e.asLog()}" } + if (inputAction == MediaStore.ACTION_IMAGE_CAPTURE || inputAction == MediaStore.ACTION_VIDEO_CAPTURE) { + callback.invoke(MediaCaptureResult.ErrorAccessingMediaApp(R.string.imageCaptureCameraUnavailable)) + } else if (inputAction == MediaStore.Audio.Media.RECORD_SOUND_ACTION) { + callback.invoke(MediaCaptureResult.ErrorAccessingMediaApp(R.string.audioCaptureSoundRecorderUnavailable)) + } + } + } + + override fun registerForResult( + caller: ActivityResultCaller, + onResult: (MediaCaptureResult) -> Unit, + ) { + callback = onResult + registerPermissionLauncher(caller) + launcher = caller.registerForActivityResult(MediaCaptureResultHandler()) { interimFile -> + if (interimFile == null) { + onResult(MediaCaptureResult.NoMediaCaptured) + } else { + appCoroutineScope.launch(dispatchers.io()) { + val finalImage = moveCapturedImageToFinalLocation(interimFile) + if (finalImage == null) { + onResult(MediaCaptureResult.NoMediaCaptured) + } else { + onResult(MediaCaptureResult.MediaCaptured(finalImage)) + } + } + } + } + } + + override fun showPermissionRationaleDialog(activity: Activity, inputAction: String) { + if (permissionHelper.isPermissionsRejectedForever(activity)) { + if (inputAction == MediaStore.ACTION_IMAGE_CAPTURE || inputAction == MediaStore.ACTION_VIDEO_CAPTURE) { + showDialog( + activity, + R.string.imageCaptureCameraPermissionDeniedTitle, + R.string.imageCaptureCameraPermissionDeniedMessage, + ) + } else if (inputAction == MediaStore.Audio.Media.RECORD_SOUND_ACTION) { + showDialog( + activity, + R.string.audioCaptureSoundRecorderPermissionDeniedTitle, + R.string.audioCaptureSoundRecorderPermissionDeniedMessage, + ) + } + } + } + + private fun showDialog(activity: Activity, @StringRes titleId: Int, @StringRes messageId: Int) { + TextAlertDialogBuilder(activity) + .setTitle(titleId) + .setMessage(messageId) + .setPositiveButton(R.string.imageCaptureCameraPermissionDeniedPositiveButton) + .setNegativeButton(R.string.imageCaptureCameraPermissionDeniedNegativeButton) + .addEventListener( + object : TextAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked() { + val intent = Intent() + intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + val uri = Uri.fromParts("package", activity.packageName, null) + intent.data = uri + activity.startActivity(intent) + } + }, + ) + .show() + } + + private fun registerPermissionLauncher(caller: ActivityResultCaller) { + permissionHelper.registerPermissionLaunchers(caller, this::onResultSystemPermissionRequest) + } + + private fun onResultSystemPermissionRequest(granted: Boolean, inputAction: String) { + logcat { "permission request received for $inputAction. granted=$granted" } + if (granted) { + launchMediaApp(inputAction) + } else { + callback(MediaCaptureResult.CouldNotCapturePermissionDenied(inputAction)) + } + } + + private suspend fun moveCapturedImageToFinalLocation(interimFile: Uri): File? { + return imageMover.moveInternal(interimFile)?.also { finalImage -> + delayedDeleter.scheduleDeletion(finalImage) + } + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/permission/RealExternalMediaSystemPermissionsHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/permission/RealExternalMediaSystemPermissionsHelper.kt new file mode 100644 index 000000000000..930e5d9974f3 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/permission/RealExternalMediaSystemPermissionsHelper.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser.capture.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.provider.MediaStore +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.duckduckgo.di.scopes.ActivityScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlin.reflect.KFunction2 + +interface ExternalMediaSystemPermissionsHelper { + fun hasMediaPermissionsGranted(inputAction: String): Boolean + fun registerPermissionLaunchers( + caller: ActivityResultCaller, + onResultPermissionRequest: KFunction2, + ) + fun requestPermission(permission: String, inputAction: String) + fun isPermissionsRejectedForever(activity: Activity): Boolean +} + +@ContributesBinding(ActivityScope::class) +class RealExternalMediaSystemPermissionsHelperImpl @Inject constructor( + private val context: Context, +) : ExternalMediaSystemPermissionsHelper { + + private lateinit var permissionLauncher: ActivityResultLauncher + private var currentPermissionRequested: String? = null + private var mediaStoreType: String = MediaStore.ACTION_IMAGE_CAPTURE + + override fun hasMediaPermissionsGranted(inputAction: String): Boolean { + return when (inputAction) { + MediaStore.ACTION_IMAGE_CAPTURE, MediaStore.ACTION_VIDEO_CAPTURE -> + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + MediaStore.Audio.Media.RECORD_SOUND_ACTION -> + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + else -> false + } + } + + override fun registerPermissionLaunchers( + caller: ActivityResultCaller, + onResultPermissionRequest: KFunction2, + ) { + permissionLauncher = caller.registerForActivityResult(RequestPermission()) { + onResultPermissionRequest.invoke(it, mediaStoreType) + } + } + + override fun requestPermission(permission: String, inputAction: String) { + if (this::permissionLauncher.isInitialized) { + currentPermissionRequested = permission + mediaStoreType = inputAction + permissionLauncher.launch(permission) + } else { + throw IllegalAccessException("registerPermissionLaunchers() needs to be called before requestPermission()") + } + } + + override fun isPermissionsRejectedForever(activity: Activity): Boolean = + currentPermissionRequested?.let { !ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } ?: true +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/DeleteMediaCaptureWorker.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/DeleteMediaCaptureWorker.kt new file mode 100644 index 000000000000..8764e28213dc --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/DeleteMediaCaptureWorker.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser.capture.postprocess + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.duckduckgo.anvil.annotations.ContributesWorker +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.withContext +import logcat.logcat + +/** + * Media captured from camera / sound recorder to be uploaded through the WebView shouldn't be kept forever. + * The URI for the content is passed to the WebView, but we don't know when it is safe to delete the file. + * + * This worker is responsible for deleting the file after a period of time. + * + * The typical use case is that: + * - the user chooses to upload an image / video / audio on a website + * - user chooses to record rather than use existing content + * - the media app is launched + * - a new file is created to store the captured content + * - we pass the URI of the file to the media app + * - we schedule the file to be deleted using this worker + */ +@ContributesWorker(AppScope::class) +class DeleteMediaCaptureWorker( + context: Context, + workerParameters: WorkerParameters, +) : + CoroutineWorker(context, workerParameters) { + + @Inject + lateinit var dispatchers: DispatcherProvider + + override suspend fun doWork(): Result { + return withContext(dispatchers.io()) { + deleteFile() + } + } + + private fun deleteFile(): Result { + val fileUri = inputData.getString(KEY_FILE_URI) ?: return Result.failure() + + val file = File(fileUri) + if (!file.exists()) { + logcat { "file doesn't exist; nothing to do here. file=${file.absolutePath}" } + return Result.success() + } + + logcat { "time to delete the temporary captured image file $file" } + + return if (file.delete()) { + logcat { "Successfully deleted the file ${file.absolutePath}" } + Result.success() + } else { + logcat { "Failed to delete the file ${file.absolutePath}" } + + if (runAttemptCount < MAX_ATTEMPTS) { + Result.retry() + } else { + Result.failure() + } + } + } + + companion object { + const val KEY_FILE_URI = "fileUri" + private const val MAX_ATTEMPTS = 10 + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/MediaCaptureDelayedDeleter.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/MediaCaptureDelayedDeleter.kt new file mode 100644 index 000000000000..a8d558489cdb --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/MediaCaptureDelayedDeleter.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser.capture.postprocess + +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.duckduckgo.di.scopes.ActivityScope +import com.squareup.anvil.annotations.ContributesBinding +import java.io.File +import java.util.concurrent.TimeUnit.SECONDS +import javax.inject.Inject + +interface MediaCaptureDelayedDeleter { + fun scheduleDeletion(file: File) +} + +@ContributesBinding(ActivityScope::class) +class WorkManagerMediaCaptureDelayedDeleter @Inject constructor( + private val workManager: WorkManager, +) : MediaCaptureDelayedDeleter { + + override fun scheduleDeletion(file: File) { + val workRequest = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + DeleteMediaCaptureWorker.KEY_FILE_URI to file.absolutePath, + ), + ) + .setInitialDelay(INITIAL_DELAY_SECONDS, SECONDS) + .build() + workManager.enqueue(workRequest) + } + + companion object { + private const val INITIAL_DELAY_SECONDS = 60L + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/MediaCaptureImageMover.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/MediaCaptureImageMover.kt new file mode 100644 index 000000000000..7c02a9800b32 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/filechooser/capture/postprocess/MediaCaptureImageMover.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser.capture.postprocess + +import android.content.Context +import android.net.Uri +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.squareup.anvil.annotations.ContributesBinding +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface MediaCaptureImageMover { + suspend fun moveInternal(contentUri: Uri): File? +} + +@ContributesBinding(ActivityScope::class) +class RealMediaCaptureImageMover @Inject constructor( + private val context: Context, + private val dispatchers: DispatcherProvider, +) : MediaCaptureImageMover { + + override suspend fun moveInternal(contentUri: Uri): File? { + return withContext(dispatchers.io()) { + val newDestinationDirectory = File(context.cacheDir, SUBDIRECTORY_NAME) + newDestinationDirectory.mkdirs() + + val filename = contentUri.lastPathSegment ?: return@withContext null + + val newDestinationFile = File(newDestinationDirectory, filename) + context.contentResolver.openInputStream(contentUri)?.use { inputStream -> + FileOutputStream(newDestinationFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + newDestinationFile + } + } + + companion object { + private const val SUBDIRECTORY_NAME = "browser-uploads" + } +} diff --git a/duckchat/duckchat-impl/src/main/res/values-bg/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-bg/strings-duckchat.xml index ffa8668de76e..c3282cb2dc06 100644 --- a/duckchat/duckchat-impl/src/main/res/values-bg/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-bg/strings-duckchat.xml @@ -21,6 +21,21 @@ Отваряне Файлът не може да се отвори. Проверете за съвместимо приложение. + + Няма достъп до камерата + Качване на изображение + Изберете файл + Направете снимка + Разрешаване на DuckDuckGo да иска достъп до камерата на това устройство + Сайтовете могат да използват камерата, само ако разрешите на DuckDuckGo да поиска достъп. + Отвори Настройки + Отмени + + + Няма достъп до звукозаписно устройство + Разрешаване на DuckDuckGo да иска достъп за звукозапис на това устройство + Сайтовете могат да използват звукозаписното устройство, само ако разрешите на DuckDuckGo да поиска достъп. + Duck.ai е незадължителна функция, чрез която можете да разговаряте анонимно с популярни AI чат модели от трети страни. Чатовете Ви не се използват за обучение на AI.\nНаучете повече Активиране на Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-cs/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-cs/strings-duckchat.xml index f82a891d8bce..7ab75d2c4d41 100644 --- a/duckchat/duckchat-impl/src/main/res/values-cs/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-cs/strings-duckchat.xml @@ -21,6 +21,21 @@ Otevřít Soubor nejde otevřít. Zkus vyhledat kompatibilní aplikaci. + + Nemáme přístup k fotoaparátu + Nahrát obrázek + Vyber soubor + Vyfotit se + Povolit DuckDuckGo žádat na tomto zařízení o přístup k fotoaparátu + Weby můžou používat fotoaparát, jen když službě DuckDuckGo povolíš žádat o přístup. + Otevřít nastavení + Zrušit + + + Nemáme přístup k záznamu zvuku + Povolit DuckDuckGo žádat na tomto zařízení o přístup k záznamu zvuku + Weby můžou používat záznam zvuku, jen když službě DuckDuckGo povolíš žádat o přístup. + Duck.ai je volitelná funkce, která umožňuje anonymně chatovat s populárními AI chatovacími modely třetích stran. Tvoje chaty neslouží k trénování umělé inteligence.\nDalší informace Povolit službu Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-da/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-da/strings-duckchat.xml index 32958d251d55..1446a8afdbc7 100644 --- a/duckchat/duckchat-impl/src/main/res/values-da/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-da/strings-duckchat.xml @@ -21,6 +21,21 @@ Åben Kan ikke åbne filen. Kontrollér, om der er en kompatibel app. + + Kunne ikke få adgang til kameraet + Upload et billede + Vælg fil + Tag et billede + Tillad DuckDuckGo at bede om adgang til kameraet på denne enhed + Websteder kan kun bruge dit kamera, hvis du giver DuckDuckGo tilladelse til at bede om adgang. + Åbn indstillinger + Annuller + + + Kunne ikke få adgang til lydoptageren + Tillad DuckDuckGo at bede om adgang til lydoptageren på denne enhed + Websteder kan kun bruge din lydoptager, hvis du giver DuckDuckGo tilladelse til at bede om adgang. + Duck.ai er en valgfri funktion, der giver dig mulighed for at chatte anonymt med populære tredjeparts AI-chatmodeller. Dine chats bruges ikke til at træne AI.\nLæs mere Aktiver Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-de/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-de/strings-duckchat.xml index 465866b6130b..b0df1580c5de 100644 --- a/duckchat/duckchat-impl/src/main/res/values-de/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-de/strings-duckchat.xml @@ -21,6 +21,21 @@ Öffnen Datei kann nicht geöffnet werden. Nach einer kompatiblen App suchen. + + Auf die Kamera konnte nicht zugegriffen werden + Bild hochladen + Datei auswählen + Foto machen + DuckDuckGo erlauben, um Zugriff auf die Kamera auf diesem Gerät zu bitten + Websites können deine Kamera nur verwenden, wenn du DuckDuckGo erlaubst, um Zugriff zu bitten. + Einstellungen öffnen + Abbrechen + + + Auf den Audiorekorder konnte nicht zugegriffen werden + DuckDuckGo erlauben, um Zugriff auf den Audiorekorder auf diesem Gerät zu bitten + Websites können deinen Audiorekorder nur verwenden, wenn du DuckDuckGo erlaubst, um Zugriff zu bitten. + Duck.ai ist eine optionale Funktion, mit der du anonym mit beliebten KI-Chatmodellen von Drittanbietern chatten kannst. Deine Chats werden nicht zum Trainieren von KI verwendet.\nMehr erfahren Aktiviere Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-el/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-el/strings-duckchat.xml index 01e657bf10a9..7805fd8b1657 100644 --- a/duckchat/duckchat-impl/src/main/res/values-el/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-el/strings-duckchat.xml @@ -21,6 +21,21 @@ Άνοιγμα Δεν ήταν δυνατό το άνοιγμα του αρχείου. Ελέγξτε για μια συμβατή εφαρμογή. + + Δεν ήταν δυνατή η πρόσβαση στην κάμερα + Μεταφορτώστε μια εικόνα + Επιλέξτε αρχείο + Τραβήξτε φωτογραφία + Επιτρέψτε στο DuckDuckGo να αιτηθεί πρόσβαση στην κάμερα της συσκευής αυτής + Οι ιστότοποι μπορούν να χρησιμοποιούν την κάμερά σας μόνο εάν επιτρέψετε στο DuckDuckGo να αιτηθεί πρόσβαση. + Άνοιγμα ρυθμίσεων + Ακύρωση + + + Δεν ήταν δυνατή η πρόσβαση στην εγγραφή ήχου + Επιτρέψτε στο DuckDuckGo να αιτηθεί πρόσβαση στην εγγραφή ήχου της συσκευής αυτής + Οι ιστότοποι μπορούν να χρησιμοποιούν την εγγραφή ήχου μόνο εάν επιτρέψετε στο DuckDuckGo να αιτηθεί πρόσβαση. + Το Duck.ai αποτελεί προαιρετική λειτουργία που σας επιτρέπει να συνομιλείτε ανώνυμα με δημοφιλή μοντέλα τρίτων για συνομιλία μέσω τεχνητής νοημοσύνης. Οι συνομιλίες σας δεν χρησιμοποιούνται για την εκπαίδευση τεχνητής συνομιλίας.\nΜάθετε περισσότερα Ενεργοποίηση του Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-es/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-es/strings-duckchat.xml index 830ef1cfcbfc..b1cb64550b76 100644 --- a/duckchat/duckchat-impl/src/main/res/values-es/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-es/strings-duckchat.xml @@ -21,6 +21,21 @@ Abrir No se puede abrir el archivo. Busca una aplicación compatible. + + No se pudo acceder a la cámara + Subir imagen + Elegir archivo + Hacer foto + Permitir que DuckDuckGo solicite acceso a la cámara en este dispositivo + Los sitios solo pueden usar tu cámara si permites que DuckDuckGo solicite acceso. + Abrir configuración + Cancelar + + + No se pudo acceder a la grabadora de sonido + Permitir que DuckDuckGo solicite acceso a la grabadora en este dispositivo + Los sitios solo pueden usar tu grabadora si permites que DuckDuckGo solicite acceso. + Duck.ai es una función opcional que te permite chatear de forma anónima con modelos populares de chat de IA de terceros. Tus chats no se utilizan para entrenar la IA.\nMás información Activa Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-et/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-et/strings-duckchat.xml index d95b10edbaaf..462814664fbc 100644 --- a/duckchat/duckchat-impl/src/main/res/values-et/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-et/strings-duckchat.xml @@ -21,6 +21,21 @@ Avatud Faili ei saa avada. Otsi ühilduvat rakendust. + + Juurdepääs kaamerale nurjus + Laadi pilt üles + Vali fail + Tee pilt + Luba DuckDuckGo’l küsida juurdepääsu selle seadme kaamerale + Saidid saavad sinu kaamerat kasutada ainult siis, kui lubad DuckDuckGo’l küsida sellele juurdepääsu. + Ava sätted + Loobu + + + Ei saanud juurdepääsu helisalvestile + Luba DuckDuckGo’l küsida juurdepääsu selle seadme helisalvestile + Saidid saavad helisalvestit kasutada ainult siis, kui lubad DuckDuckGo’l küsida sellele juurdepääsu. + Duck.ai on valikuline funktsioon, mis võimaldab anonüümselt vestelda populaarsete kolmanda poole AI vestlusmudelitega. Teie vestlusi ei kasutata tehisintellekti treenimiseks.\nLisateave Luba Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-fi/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-fi/strings-duckchat.xml index c5d74960593f..108ed53e70f9 100644 --- a/duckchat/duckchat-impl/src/main/res/values-fi/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-fi/strings-duckchat.xml @@ -21,6 +21,21 @@ Avaa Tiedoston avaaminen ei onnistu. Tarkista yhteensopiva sovellus. + + Kameraa ei voitu käyttää + Lataa kuva + Valitse tiedosto + Ota valokuva + Salli DuckDuckGon pyytää kameran käyttöoikeutta tällä laitteella + Sivustot voivat käyttää kameraa vain, jos sallit DuckDuckGon pyytää sen käyttöoikeutta. + Avaa asetukset + Peruuta + + + Äänentallenninta ei voitu käyttää + Salli DuckDuckGon pyytää äänitallentimen käyttöoikeutta tällä laitteella + Sivustot voivat käyttää äänitallenninta vain, jos sallit DuckDuckGon pyytää sen käyttöoikeutta. + Duck.ai on valinnainen ominaisuus, jonka avulla voit keskustella anonyymisti suosittujen kolmannen osapuolen tekoälykeskustelumallien kanssa. Keskustelujasi ei käytetä tekoälyn kouluttamiseen.\nLue lisää Ota Duck.ai käyttöön diff --git a/duckchat/duckchat-impl/src/main/res/values-fr/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-fr/strings-duckchat.xml index 4029d071f89a..11234b3f5870 100644 --- a/duckchat/duckchat-impl/src/main/res/values-fr/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-fr/strings-duckchat.xml @@ -21,6 +21,21 @@ Ouvrir Impossible d\'ouvrir le fichier. Vérifiez la compatibilité de l\'application. + + Impossible d\'accéder à l\'appareil photo + Télécharger une image + Sélectionner un fichier + Prendre une photo + Autoriser DuckDuckGo à demander l\'accès à l\'appareil photo sur cet appareil + Les sites ne peuvent utiliser votre appareil photo que si vous autorisez DuckDuckGo à demander l\'accès. + Ouvrir les paramètres + Annuler + + + Impossible d\'accéder à l\'enregistreur de son + Autoriser DuckDuckGo à demander l\'accès à l\'enregistreur audio sur cet appareil + Les sites ne peuvent utiliser l\'enregistreur audio que si vous autorisez DuckDuckGo à demander l\'accès. + Duck.ai est une fonctionnalité optionnelle qui vous permet de discuter anonymement avec des modèles de chat IA tiers populaires. Vos discussions ne servent pas à entraîner l’IA.\nEn savoir plus Activer Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-hr/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-hr/strings-duckchat.xml index fdb3058e170c..9774cda9edb1 100644 --- a/duckchat/duckchat-impl/src/main/res/values-hr/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-hr/strings-duckchat.xml @@ -21,6 +21,21 @@ Otvori Datoteka se ne može otvoriti. Provjerite postoji li kompatibilna aplikacija. + + Nije moguće pristupiti kameri + Učitaj sliku + Odaberi datoteku + Snimi fotografiju + Dopusti DuckDuckGou da zatraži pristup kameri na ovom uređaju + Stranice mogu koristiti tvoju kameru samo ako dopustiš DuckDuckGou da zatraži pristup. + Otvori postavke + Odustani + + + Pristup snimaču zvuka nije bio moguć + Dopusti DuckDuckGou da zatraži pristup snimaču zvuka na ovom uređaju + Mrežne lokacije mogu koristiti snimač zvuka samo ako dopustiš DuckDuckGou da zatraži pristup. + Duck.ai izborna je značajka koja ti omogućuje anonimno čavrljanje s popularnim AI modelima \'chata\' trećih strana. Tvoja čavrljanja se ne koriste za obuku AI-a.\nSaznaj više Omogući Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-hu/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-hu/strings-duckchat.xml index 47016454c519..6430af8408af 100644 --- a/duckchat/duckchat-impl/src/main/res/values-hu/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-hu/strings-duckchat.xml @@ -21,6 +21,21 @@ Megnyitás A fájl nem nyitható meg. Keress kompatibilis alkalmazást. + + Nem sikerült hozzáférni a kamerához + Kép feltöltése + Fájl kiválasztása + Fénykép készítése + Engedélyezés, hogy a DuckDuckGo hozzáférést kérjen a kamerához ezen az eszközön + A webhelyek csak akkor használhatják a kamerát, ha engedélyezed, hogy a DuckDuckGo hozzáférést kérjen. + Beállítások megnyitása + Mégsem + + + Nem sikerült hozzáférni a hangrögzítőhöz + Engedélyezd, hogy a DuckDuckGo hozzáférést kérhessen az eszközöd hangrögzítőjéhez + A webhelyek csak akkor használhatják a hangrögzítőt, ha engedélyezed, hogy a DuckDuckGo hozzáférést kérhessen. + A Duck.ai egy opcionális funkció, amely lehetővé teszi a harmadik féltől származó népszerű AI-csevegési modellekkel való névtelen csevegést. A csevegéseidet nem használjuk fel az AI tanítására.\nTovábbi részletek Duck.ai engedélyezése diff --git a/duckchat/duckchat-impl/src/main/res/values-it/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-it/strings-duckchat.xml index aee103777f1b..06ba46088810 100644 --- a/duckchat/duckchat-impl/src/main/res/values-it/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-it/strings-duckchat.xml @@ -21,6 +21,21 @@ Apri Impossibile aprire il file. Verifica la disponibilità di un\'app compatibile. + + Impossibile accedere alla fotocamera + Carica un\'immagine + Scegli un file + Scatta una foto + Consenti a DuckDuckGo di richiedere l\'accesso alla fotocamera su questo dispositivo + I siti possono utilizzare la fotocamera solo se consenti a DuckDuckGo di richiederne l\'accesso. + Apri Impostazioni + Annulla + + + Impossibile accedere al registratore audio + Consenti a DuckDuckGo di richiedere l\'accesso al registratore audio su questo dispositivo + I siti possono utilizzare il registratore audio solo se consenti a DuckDuckGo di richiederne l\'accesso. + Duck.ai è una funzionalità opzionale che consente di chattare in forma anonima con i più diffusi modelli di chat AI di terze parti. Le chat non vengono usate per addestrare l\'IA.\nScopri di più Abilita Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-lt/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-lt/strings-duckchat.xml index eb11c8eb0bed..9e3effd1faf8 100644 --- a/duckchat/duckchat-impl/src/main/res/values-lt/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-lt/strings-duckchat.xml @@ -21,6 +21,21 @@ Atviras Nepavyko atidaryti failo. Patikrinkite, ar yra suderinama programa. + + Nepavyko pasiekti fotoaparato + Įkelti vaizdą + Pasirinkti failą + Padaryti nuotrauką + Leisti „DuckDuckGo“ prašyti prieigos prie kameros šiame įrenginyje + Svetainės gali naudoti jūsų kamerą tik tada, jei leisite „DuckDuckGo“ prašyti prieigos. + Atidaryti nustatymus + Atšaukti + + + Nepavyko pasiekti garso įrašytuvo + Leisti „DuckDuckGo“ prašyti prieigos prie garso įrašymo įrenginio šiame įrenginyje + Svetainės gali naudoti garso įrašymo įrenginį tik tuo atveju, jei leisite „DuckDuckGo“ prašyti prieigos. + „Duck.ai“ yra pasirenkama funkcija, leidžianti anonimiškai kalbėtis su populiariais trečiųjų šalių DI pokalbių modeliais. Tavo pokalbiai nenaudojami dirbtinio intelekto mokymui.\nSužinok daugiau Įjungti „Duck.ai“ diff --git a/duckchat/duckchat-impl/src/main/res/values-lv/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-lv/strings-duckchat.xml index bef9a0c959f6..49e7949a7162 100644 --- a/duckchat/duckchat-impl/src/main/res/values-lv/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-lv/strings-duckchat.xml @@ -21,6 +21,21 @@ Atvērt Nevar atvērt failu. Pārbaudīt saderīgu lietotni. + + Nevar piekļūt kamerai + Augšupielādēt attēlu + Izvēlēties failu + Uzņemt foto + Atļaut DuckDuckGo pieprasīt piekļuvi kamerai šajā ierīcē + Vietnes var izmantot tavu kameru tikai tad, ja tu atļauj DuckDuckGo prasīt piekļuvi. + Atvērt iestatījumus + Atcelt + + + Nevar piekļūt skaņas ierakstītājam + Atļaut DuckDuckGo pieprasīt piekļuvi skaņas ierakstītājam šajā ierīcē + Vietnes var izmantot skaņas ierakstītāju tikai tad, ja tu atļauj DuckDuckGo prasīt piekļuvi. + Duck.ai ir papildu līdzeklis, kas ļauj anonīmi tērzēt ar populāriem trešo pušu Ml tērzēšanas modeļiem. Tavas tērzēšanas sarunas netiek izmantotas AMI apmācībai.\nUzzini vairāk Iespējot Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-nb/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-nb/strings-duckchat.xml index bda3c3cd134d..148df5612b6f 100644 --- a/duckchat/duckchat-impl/src/main/res/values-nb/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-nb/strings-duckchat.xml @@ -21,6 +21,21 @@ Åpne Kan ikke åpne filen. Se etter kompatibel app. + + Fikk ikke tilgang til kameraet + Last opp et bilde + Velg fil + Ta bilde + Tillat DuckDuckGo å be om kameratilgang på denne enheten + Nettsteder kan bare bruke kameraet ditt hvis du lar DuckDuckGo be om tilgang. + Åpne innstillinger + Avbryt + + + Fikk ikke tilgang til lydopptaker + Tillat DuckDuckGo å be om tilgang til lydopptaker på denne enheten + Nettsteder kan bare bruke lydopptakeren hvis du lar DuckDuckGo be om tilgang. + Duck.ai er en valgfri funksjon der du kan chatte anonymt med populære tredjeparts AI-chat-modeller. Chattene dine brukes ikke til å trene AI.\nLes mer Aktiver Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-nl/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-nl/strings-duckchat.xml index f71e88eb70e9..898515d17f64 100644 --- a/duckchat/duckchat-impl/src/main/res/values-nl/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-nl/strings-duckchat.xml @@ -21,6 +21,21 @@ Openen Kan bestand niet openen. Kijk of er een compatibele app is. + + Kan geen toegang krijgen tot de camera + Upload een afbeelding + Bestand kiezen + Foto maken + DuckDuckGo toestaan om toegang tot de camera op dit apparaat te vragen + Sites kunnen je camera alleen gebruiken als je DuckDuckGo toestaat om toegang te vragen. + Open Instellingen + Annuleren + + + Kan geen toegang krijgen tot de geluidsrecorder + DuckDuckGo toestaan om toegang tot de geluidsrecorder op dit apparaat te vragen + Sites kunnen de geluidsrecorder alleen gebruiken als je DuckDuckGo toestaat om toegang te vragen. + Duck.ai is een optionele functie waarmee je anoniem kunt chatten met populaire AI-chatmodellen van derden. Je chats worden niet gebruikt om AI te trainen.\nMeer informatie Duck.ai inschakelen diff --git a/duckchat/duckchat-impl/src/main/res/values-pl/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-pl/strings-duckchat.xml index b790c9ad78e2..a8a5d4751745 100644 --- a/duckchat/duckchat-impl/src/main/res/values-pl/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-pl/strings-duckchat.xml @@ -21,6 +21,21 @@ Otwórz Nie można otworzyć pliku. Poszukaj zgodnej aplikacji. + + Nie można uzyskać dostępu do aparatu + Prześlij obraz + Wybierz plik + Zrób zdjęcie + Pozwól DuckDuckGo prosić o dostęp do kamery na tym urządzeniu + Witryny mogą korzystać z Twojej kamery tylko wtedy, gdy pozwolisz DuckDuckGo poprosić o dostęp. + Otwórz ustawienia + Anuluj + + + Nie można uzyskać dostępu do rejestratora dźwięku + Zezwól DuckDuckGo na żądanie dostępu do rejestratora dźwięku na tym urządzeniu + Witryny mogą korzystać z rejestratora dźwięku tylko po zezwoleniu DuckDuckGo na żądanie dostępu. + Duck.ai to opcjonalna funkcja, która umożliwia prowadzenie anonimowych rozmów z popularnymi modelami czatu AI innych firm. Twoje czaty nie są używane do trenowania AI.\nDowiedz się więcej Włącz Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-pt/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-pt/strings-duckchat.xml index 6964da4a3391..c2c7a853197f 100644 --- a/duckchat/duckchat-impl/src/main/res/values-pt/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-pt/strings-duckchat.xml @@ -21,6 +21,21 @@ Abrir Não é possível abrir o ficheiro. Verificar se a aplicação é compatível. + + Não foi possível aceder à câmara + Carrega uma imagem + Escolher ficheiro + Tirar fotografia + Permitir que o DuckDuckGo peça acesso à câmara neste dispositivo + Os sites só podem utilizar a tua câmara se autorizares o DuckDuckGo a pedir acesso. + Abrir Definições + Cancelar + + + Não foi possível aceder ao gravador de som + Permitir que o DuckDuckGo peça acesso ao gravador de som neste dispositivo + Os sites só podem utilizar o gravador de som se autorizares o DuckDuckGo a pedir acesso. + O Duck.ai é uma funcionalidade opcional que te permite conversar anonimamente com modelos de chat de IA populares de terceiros. As tuas conversas não são utilizadas para treinar a IA.\nSabe mais Ativar Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-ro/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-ro/strings-duckchat.xml index d1cf4b019a10..c44e86476a36 100644 --- a/duckchat/duckchat-impl/src/main/res/values-ro/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-ro/strings-duckchat.xml @@ -21,6 +21,21 @@ Deschide Nu se poate deschide fișierul. Caută o aplicație compatibilă. + + Nu s-a putut accesa camera + Încarcă o imagine + Alege fișierul + Fă o fotografie + Permite ca DuckDuckGo să solicite accesul la cameră pe acest dispozitiv + Site-urile îți pot utiliza camera numai dacă permiți ca DuckDuckGo să solicite accesul. + Deschide Setări + Anulare + + + Nu s-a putut accesa înregistratorul de sunet + Permite ca DuckDuckGo să solicite accesul la înregistratorul de sunet pe acest dispozitiv + Site-urile pot utiliza înregistratorul de sunet numai dacă permiți ca DuckDuckGo să solicite accesul. + Duck.ai este o caracteristică opțională care îți permite să discuți anonim cu modele populare de chat AI de la terțe părți. Chaturile tale nu sunt folosite pentru a antrena AI.\nAflă mai multe Activează Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-ru/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-ru/strings-duckchat.xml index 78a8ac7dcb5b..53605a7aeabb 100644 --- a/duckchat/duckchat-impl/src/main/res/values-ru/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-ru/strings-duckchat.xml @@ -21,6 +21,21 @@ Открыть Не удается открыть файл. Попробуйте найти совместимое приложение. + + Нет доступа к камере + Загрузка изображения + Выбрать файл + Снять фото + Разрешить DuckDuckGo запрашивать доступ к камере на этом устройстве + Если вы разрешите DuckDuckGo запрашивать доступ, сайты смогут использовать камеру. + Открыть Настройки + Отменить + + + Нет доступа к диктофону + Разрешить DuckDuckGo запрашивать доступ к диктофону на этом устройстве + Сайты получат возможность пользоваться диктофоном, только если вы разрешите DuckDuckGo запрашивать доступ. + Duck.ai — дополнительная функция, позволяющая анонимно беседовать с популярными сторонними чат-моделями на базе искусственного интеллекта. Содержимое чатов не применяется для обучения ИИ.\nПодробнее... Включить Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-sk/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-sk/strings-duckchat.xml index f3b0b241add8..3f6fe42336df 100644 --- a/duckchat/duckchat-impl/src/main/res/values-sk/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-sk/strings-duckchat.xml @@ -21,6 +21,21 @@ Otvorené Súbor sa nedá otvoriť. Vyhľadajte kompatibilnú aplikáciu. + + Nepodarilo sa získať prístup k fotoaparátu + Nahrať obrázok + Vyberte si súbor + Urobiť fotografiu + Povoliť aplikácii DuckDuckGo žiadať o prístup k fotoaparátu v tomto zariadení + Lokality môžu používať váš fotoaparát len vtedy, ak povolíte aplikácii DuckDuckGo žiadať o prístup. + Otvoriť nastavenia + Zrušiť + + + Nepodarilo sa získať prístup k zvukovému záznamníku + Povoliť aplikácii DuckDuckGo žiadať o prístup k záznamníku zvuku v tomto zariadení + Lokality môžu používať záznamník zvuku len vtedy, ak povolíš aplikácii DuckDuckGo žiadať o prístup. + Duck.ai je voliteľná funkcia, ktorá ti umožňuje anonymne chatovať s obľúbenými modelmi chatu s umelou inteligenciou tretích strán. Tvoje chaty sa nepoužívajú na trénovanie AI.\nViac informácií Povoliť Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-sl/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-sl/strings-duckchat.xml index 13b6be3ba2a3..262aa41c9c18 100644 --- a/duckchat/duckchat-impl/src/main/res/values-sl/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-sl/strings-duckchat.xml @@ -21,6 +21,21 @@ Odpri Datoteke ni mogoče odpreti. Poiščite združljivo aplikacijo. + + Ni bilo mogoče dostopati do kamere + Naložite sliko + Izberite datoteko + Posnemite fotografijo + Dovolite, da lahko spletno mesto DuckDuckGo zahteva dostop do kamere v tej napravi + Spletna mesta lahko uporabljajo vašo kamero le, če dovolite, da DuckDuckGo zaprosi za dostop. + Odpri nastavitve + Prekliči + + + Ni bilo mogoče dostopati do snemalnika zvoka + Dovolite, da lahko DuckDuckGo zahteva dostop do snemalnika zvoka v tej napravi + Spletna mesta lahko uporabljajo snemalnik zvoka le, če dovolite, da DuckDuckGo zaprosi za dostop. + Duck.ai je izbirna funkcija, ki vam omogoča anonimen klepet s priljubljenimi modeli za klepet z umetno inteligenco drugih ponudnikov. Vaši klepeti se ne uporabljajo za usposabljanje umetne inteligence.\nVeč o tem Omogoči Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-sv/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-sv/strings-duckchat.xml index 6266edc5f77e..0aa06a6ab3be 100644 --- a/duckchat/duckchat-impl/src/main/res/values-sv/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-sv/strings-duckchat.xml @@ -21,6 +21,21 @@ Öppna Det går inte att öppna filen. Sök efter en kompatibel app. + + Det gick inte att komma åt kameran + Ladda upp en bild + Välj fil + Ta foto + Tillåt DuckDuckGo att be om kameraåtkomst på den här enheten + Webbplatser kan endast använda din kamera om du tillåter DuckDuckGo att be om åtkomst. + Öppna inställningar + Avbryt + + + Det gick inte att komma åt ljudinspelaren + Tillåt DuckDuckGo att be om åtkomst till ljudinspelaren på den här enheten + Webbplatser kan endast använda ljudinspelaren om du tillåter DuckDuckGo att be om åtkomst. + Duck.ai är en valfri funktion som låter dig chatta anonymt med populära AI-chattmodeller från tredje part. Dina chattar används inte för att träna AI.\nLäs mer Aktivera Duck.ai diff --git a/duckchat/duckchat-impl/src/main/res/values-tr/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values-tr/strings-duckchat.xml index ab15f8052887..42b82199503b 100644 --- a/duckchat/duckchat-impl/src/main/res/values-tr/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values-tr/strings-duckchat.xml @@ -21,6 +21,21 @@ Dosya açılamıyor. Uyumlu uygulama arayın. + + Kameraya erişilemedi + Bir resim yükle + Dosya Seç + Fotoğraf Çek + DuckDuckGo\'nun bu cihazda kamera erişimi istemesine izin ver + Siteler kameranızı yalnızca DuckDuckGo\'nun erişim istemesine izin verirseniz kullanabilir. + Ayarları Aç + Vazgeç + + + Ses kaydediciye erişilemedi + DuckDuckGo\'nun bu cihazda ses kaydedici erişimi istemesine izin ver + Siteler, ses kaydediciyi yalnızca DuckDuckGo\'nun erişim istemesine izin verirseniz kullanabilir. + Duck.ai, popüler 3. taraf yapay zeka sohbet modelleriyle anonim olarak sohbet etmenizi sağlayan isteğe bağlı bir özelliktir. Sohbetler yapay zekayı eğitmek için kullanılmaz.\nDaha Fazla Bilgi Duck.ai\'ı etkinleştir diff --git a/duckchat/duckchat-impl/src/main/res/values/strings-duckchat.xml b/duckchat/duckchat-impl/src/main/res/values/strings-duckchat.xml index a07be8658139..bf859c2cfc1d 100644 --- a/duckchat/duckchat-impl/src/main/res/values/strings-duckchat.xml +++ b/duckchat/duckchat-impl/src/main/res/values/strings-duckchat.xml @@ -20,6 +20,21 @@ Open Can\'t open file. Check for compatible app. + + Could not access camera + Upload an image + Choose File + Take Photo + Allow DuckDuckGo to ask for camera access on this device + Sites can only use your camera if you allow DuckDuckGo to ask for access. + Open Settings + Cancel + + + Could not access sound recorder + Allow DuckDuckGo to ask for sound recorder access on this device + Sites can only use the sound recorder if you allow DuckDuckGo to ask for access. + Duck.ai is an optional feature that lets you chat anonymously with popular 3rd-party Al chat models. Your chats are not used to train AI.\nLearn More Enable Duck.ai diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt index 896f8c52de01..15c4bfad56d1 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/RealDuckChatTest.kt @@ -28,6 +28,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.ui.experiments.visual.store.VisualDesignExperimentDataStore import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams +import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature import com.duckduckgo.duckchat.impl.feature.DuckChatFeature import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository @@ -79,6 +80,7 @@ class RealDuckChatTest { private val mockPixel: Pixel = mock() private val mockIntent: Intent = mock() private val mockExperimentDataStore: VisualDesignExperimentDataStore = mock() + private val imageUploadFeature: AIChatImageUploadFeature = FakeFeatureToggleFactory.create(AIChatImageUploadFeature::class.java) private lateinit var testee: RealDuckChat @@ -89,6 +91,7 @@ class RealDuckChatTest { whenever(mockDuckChatFeatureRepository.isDuckChatUserEnabled()).thenReturn(true) whenever(mockContext.getString(any())).thenReturn("Duck.ai") duckChatFeature.self().setRawStoredState(State(enable = true)) + imageUploadFeature.self().setRawStoredState(State(enable = true)) testee = spy( RealDuckChat( @@ -102,6 +105,7 @@ class RealDuckChatTest { coroutineRule.testScope, mockPixel, mockExperimentDataStore, + imageUploadFeature, ), ) coroutineRule.testScope.advanceUntilIdle() @@ -579,6 +583,15 @@ class RealDuckChatTest { collectJob.cancel() } + @Test + fun whenImageUploadFeatureDisabledThenDisableImageUpload() = runTest { + imageUploadFeature.self().setRawStoredState(State(enable = false)) + + testee.onPrivacyConfigDownloaded() + + assertFalse(testee.isImageUploadEnabled()) + } + companion object { val SETTINGS_JSON = """ { diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt index 33d514f4dd77..c62e2c1dfd30 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt @@ -179,6 +179,7 @@ class RealDuckChatJSHelperTest { put("supportsClosingAIChat", true) put("supportsOpeningSettings", true) put("supportsNativeChatInput", false) + put("supportsImageUpload", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -205,6 +206,7 @@ class RealDuckChatJSHelperTest { put("supportsClosingAIChat", true) put("supportsOpeningSettings", true) put("supportsNativeChatInput", false) + put("supportsImageUpload", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -365,6 +367,30 @@ class RealDuckChatJSHelperTest { put("supportsClosingAIChat", true) put("supportsOpeningSettings", true) put("supportsNativeChatInput", true) + put("supportsImageUpload", false) + } + + assertEquals(expectedPayload.toString(), result!!.params.toString()) + } + + @Test + fun whenGetAIChatNativeConfigValuesAndSupportsImageUploadThenReturnJsCallbackDataWithSupportsImageUploadEnabled() = runTest { + val featureName = "aiChat" + val method = "getAIChatNativeConfigValues" + val id = "123" + + whenever(mockDuckChat.isEnabled()).thenReturn(true) + whenever(mockDuckChat.isImageUploadEnabled()).thenReturn(true) + + val result = testee.processJsCallbackMessage(featureName, method, id, null) + + val expectedPayload = JSONObject().apply { + put("platform", "android") + put("isAIChatHandoffEnabled", true) + put("supportsClosingAIChat", true) + put("supportsOpeningSettings", true) + put("supportsNativeChatInput", false) + put("supportsImageUpload", true) } assertEquals(expectedPayload.toString(), result!!.params.toString()) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/filechooser/FileChooserIntentBuilderTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/filechooser/FileChooserIntentBuilderTest.kt new file mode 100644 index 000000000000..916c44ae2e52 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/ui/filechooser/FileChooserIntentBuilderTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.ui.filechooser + +import android.content.ClipData +import android.content.ClipData.Item +import android.content.ClipDescription +import android.content.ClipDescription.MIMETYPE_TEXT_URILIST +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FileChooserIntentBuilderTest { + + private lateinit var testee: FileChooserIntentBuilder + + @Before + fun setup() { + testee = FileChooserIntentBuilder() + } + + @Test + fun whenIntentBuiltThenReadUriPermissionFlagSet() { + val output = testee.intent(emptyArray()) + assertTrue("Intent.FLAG_GRANT_READ_URI_PERMISSION flag not set on intent", output.hasFlagSet(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + } + + @Test + fun whenIntentBuiltThenAcceptTypeSetToAll() { + val output = testee.intent(emptyArray()) + assertEquals("*/*", output.type) + } + + @Test + fun whenMultipleModeDisabledThenIntentExtraReturnsFalse() { + val output = testee.intent(emptyArray(), canChooseMultiple = false) + assertFalse(output.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) + } + + @Test + fun whenRequestedTypesAreMissingThenShouldNotAddMimeTypeExtra() { + val output = testee.intent(emptyArray()) + assertFalse(output.hasExtra(Intent.EXTRA_MIME_TYPES)) + } + + @Test + fun whenRequestedTypesArePresentThenShouldAddMimeTypeExtra() { + val output = testee.intent(arrayOf("image/png", "image/gif")) + assertTrue(output.hasExtra(Intent.EXTRA_MIME_TYPES)) + } + + @Test + fun whenUpperCaseTypesGivenThenNormalisedToLowercase() { + val output = testee.intent(arrayOf("ImAgE/PnG")) + assertEquals("image/png", output.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)!![0]) + } + + @Test + fun whenEmptyTypesGivenThenNotIncludedInOutput() { + val output = testee.intent(arrayOf("image/png", "", " ", "image/gif")) + val mimeTypes = output.getStringArrayExtra(Intent.EXTRA_MIME_TYPES) + assertEquals(2, mimeTypes!!.size) + assertEquals("image/png", mimeTypes[0]) + assertEquals("image/gif", mimeTypes[1]) + } + + @Test + fun whenMultipleModeEnabledThenIntentExtraReturnsTrue() { + val output = testee.intent(emptyArray(), canChooseMultiple = true) + assertTrue(output.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) + } + + @Test + fun whenExtractingSingleUriEmptyClipThenSingleUriReturned() { + val intent = buildIntent("a") + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(1, extractedUris!!.size) + assertEquals("a", extractedUris.first().toString()) + } + + @Test + fun whenExtractingSingleUriNonEmptyClipThenUriReturnedFromClip() { + val intent = buildIntent("a", listOf("b")) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(1, extractedUris!!.size) + assertEquals("b", extractedUris.first().toString()) + } + + @Test + fun whenExtractingMultipleClipItemsThenCorrectUrisReturnedFromClip() { + val intent = buildIntent("a", listOf("b", "c")) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(2, extractedUris!!.size) + assertEquals("b", extractedUris[0].toString()) + assertEquals("c", extractedUris[1].toString()) + } + + @Test + fun whenExtractingSingleUriMissingButClipDataAvailableThenUriReturnedFromClip() { + val intent = buildIntent(clipData = listOf("b")) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertEquals(1, extractedUris!!.size) + assertEquals("b", extractedUris.first().toString()) + } + + @Test + fun whenNoDataOrClipDataThenNullUriReturned() { + val intent = buildIntent(data = null, clipData = null) + val extractedUris = testee.extractSelectedFileUris(intent) + + assertNull(extractedUris) + } + + /** + * Helper function to build an `Intent` which contains one or more of `data` and `clipData` values. + * + * This is a bit messy but the Intent APIs are messy themselves; at least this contains the mess to this one helper function + */ + + private fun buildIntent( + data: String? = null, + clipData: List? = null, + ): Intent { + return Intent().also { + if (data != null) { + it.data = data.toUri() + } + + if (clipData != null && clipData.isNotEmpty()) { + val clipDescription = ClipDescription("", arrayOf(MIMETYPE_TEXT_URILIST)) + it.clipData = ClipData(clipDescription, Item(Uri.parse(clipData.first()))) + + for (i in 1 until clipData.size) { + it.clipData?.addItem(Item(Uri.parse(clipData[i]))) + } + } + } + } + + private fun Intent.hasFlagSet(expectedFlag: Int): Boolean { + val actual = flags and expectedFlag + return expectedFlag == actual + } +}