From faf22d082359d4c81bb481ce57fe453745526816 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 24 Feb 2026 00:02:18 +0100 Subject: [PATCH 1/7] chore: Migrate fullscreen text viewer layout to Composables Signed-off-by: Andy Scherzinger --- .../fullscreenfile/FullScreenTextScreen.kt | 129 ++++++++++++++++++ .../FullScreenTextViewerActivity.kt | 113 +++++---------- .../res/layout/activity_full_screen_text.xml | 53 ------- 3 files changed, 163 insertions(+), 132 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt delete mode 100644 app/src/main/res/layout/activity_full_screen_text.xml diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt new file mode 100644 index 00000000000..31e239abf9a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt @@ -0,0 +1,129 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-FileCopyrightText: 2021 Marcel Hibbe + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.fullscreenfile + +import android.content.res.Configuration +import android.widget.TextView +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.nextcloud.talk.R +import com.nextcloud.talk.components.StandardAppBar +import io.noties.markwon.Markwon + +@Composable +fun FullScreenTextScreen(title: String, text: String, isMarkdown: Boolean, onShare: () -> Unit, onSave: () -> Unit) { + val menuItems = listOf( + stringResource(R.string.share) to onShare, + stringResource(R.string.nc_save_message) to onSave + ) + + Scaffold( + topBar = { StandardAppBar(title = title, menuItems = menuItems) }, + contentWindowInsets = WindowInsets.safeDrawing + ) { paddingValues -> + if (isMarkdown) { + AndroidView( + factory = { ctx -> + TextView(ctx).apply { + setTextIsSelectable(true) + val markwon = Markwon.create(ctx) + markwon.setMarkdown(this, text) + } + }, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(start = 16.dp, top = 0.dp, end = 16.dp, bottom = 0.dp) + ) + } else { + SelectionContainer( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 0.dp, end = 16.dp, bottom = 0.dp) + ) + } + } + } +} + +private const val PREVIEW_TEXT = """ +# Heading + +This is a sample paragraph with **bold** and *italic* text. + +- Item one +- Item two +- Item three +""" + +@Preview(name = "Light", showBackground = true) +@Composable +private fun PreviewFullScreenTextScreenLight() { + MaterialTheme(colorScheme = lightColorScheme()) { + FullScreenTextScreen( + title = "notes.md", + text = PREVIEW_TEXT, + isMarkdown = false, + onShare = {}, + onSave = {} + ) + } +} + +@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewFullScreenTextScreenDark() { + MaterialTheme(colorScheme = darkColorScheme()) { + FullScreenTextScreen( + title = "notes.md", + text = PREVIEW_TEXT, + isMarkdown = false, + onShare = {}, + onSave = {} + ) + } +} + +@Preview(name = "RTL - Arabic", showBackground = true, locale = "ar") +@Composable +private fun PreviewFullScreenTextScreenRtl() { + MaterialTheme(colorScheme = lightColorScheme()) { + FullScreenTextScreen( + title = "ملاحظات.md", + text = "هذا نص تجريبي باللغة العربية لاختبار تخطيط الواجهة من اليمين إلى اليسار.", + isMarkdown = false, + onShare = {}, + onSave = {} + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt index 00bb5ad6848..5445f296ce0 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt @@ -10,114 +10,69 @@ package com.nextcloud.talk.fullscreenfile import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.MaterialTheme import androidx.core.content.FileProvider -import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.databinding.ActivityFullScreenTextBinding +import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.Mimetype.TEXT_PREFIX_GENERIC -import io.noties.markwon.Markwon +import com.nextcloud.talk.utils.adjustUIForAPILevel35 import java.io.File import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class FullScreenTextViewerActivity : AppCompatActivity() { - lateinit var binding: ActivityFullScreenTextBinding @Inject lateinit var viewThemeUtils: ViewThemeUtils - private lateinit var path: String - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_preview, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - - R.id.share -> { - val shareUri = FileProvider.getUriForFile( - this, - BuildConfig.APPLICATION_ID, - File(path) - ) - - val shareIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, shareUri) - type = TEXT_PREFIX_GENERIC - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) - - true - } - - R.id.save -> { - val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance( - intent.getStringExtra("FILE_NAME").toString() - ) - saveFragment.show( - supportFragmentManager, - SaveToStorageDialogFragment.TAG - ) - true - } - - else -> { - super.onOptionsItemSelected(item) - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - binding = ActivityFullScreenTextBinding.inflate(layoutInflater) - setContentView(binding.root) - - setSupportActionBar(binding.textviewToolbar) - - val fileName = intent.getStringExtra("FILE_NAME") + val fileName = intent.getStringExtra("FILE_NAME").orEmpty() val isMarkdown = intent.getBooleanExtra("IS_MARKDOWN", false) - path = applicationContext.cacheDir.absolutePath + "/" + fileName + val path = applicationContext.cacheDir.absolutePath + "/" + fileName val text = readFile(path) - if (isMarkdown) { - val markwon = Markwon.create(applicationContext) - markwon.setMarkdown(binding.textView, text) - } else { - binding.textView.text = text + adjustUIForAPILevel35() + + setContent { + val colorScheme = viewThemeUtils.getColorScheme(this) + MaterialTheme(colorScheme = colorScheme) { + ColoredStatusBar() + FullScreenTextScreen( + title = fileName, + text = text, + isMarkdown = isMarkdown, + onShare = { shareFile(path) }, + onSave = { showSaveDialog(fileName) } + ) + } } + } - supportActionBar?.title = fileName - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - viewThemeUtils.platform.themeStatusBar(this) - viewThemeUtils.material.themeToolbar(binding.textviewToolbar) - viewThemeUtils.material.colorToolbarOverflowIcon(binding.textviewToolbar) - - if (resources != null) { - DisplayUtils.applyColorToNavigationBar( - this.window, - ResourcesCompat.getColor(resources, R.color.bg_default, null) - ) + private fun shareFile(path: String) { + val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, File(path)) + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, shareUri) + type = TEXT_PREFIX_GENERIC + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) + } + + private fun showSaveDialog(fileName: String) { + val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(fileName) + saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) } private fun readFile(fileName: String) = File(fileName).inputStream().readBytes().toString(Charsets.UTF_8) diff --git a/app/src/main/res/layout/activity_full_screen_text.xml b/app/src/main/res/layout/activity_full_screen_text.xml deleted file mode 100644 index 26bd55540e5..00000000000 --- a/app/src/main/res/layout/activity_full_screen_text.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - From 8af6e72dca7630e8dfc30c370ada343a3350845c Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 27 Feb 2026 11:59:20 +0100 Subject: [PATCH 2/7] feat: Pass on file info to also open the file for potential editing via Files app within the full screen activity Resolves #1683 Signed-off-by: Andy Scherzinger --- .../fullscreenfile/FullScreenTextScreen.kt | 20 ++++++++--- .../FullScreenTextViewerActivity.kt | 35 ++++++++++++++++++- .../nextcloud/talk/utils/FileViewerUtils.kt | 26 ++++++++++---- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt index 31e239abf9a..ada97f5c813 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt @@ -34,11 +34,21 @@ import com.nextcloud.talk.components.StandardAppBar import io.noties.markwon.Markwon @Composable -fun FullScreenTextScreen(title: String, text: String, isMarkdown: Boolean, onShare: () -> Unit, onSave: () -> Unit) { - val menuItems = listOf( - stringResource(R.string.share) to onShare, - stringResource(R.string.nc_save_message) to onSave - ) +fun FullScreenTextScreen( + title: String, + text: String, + isMarkdown: Boolean, + onShare: () -> Unit, + onSave: () -> Unit, + onOpenInFilesApp: (() -> Unit)? = null +) { + val menuItems = buildList { + add(stringResource(R.string.share) to onShare) + add(stringResource(R.string.nc_save_message) to onSave) + if (onOpenInFilesApp != null) { + add(stringResource(R.string.open_in_files_app) to onOpenInFilesApp) + } + } Scaffold( topBar = { StandardAppBar(title = title, menuItems = menuItems) }, diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt index 5445f296ce0..f0c954b66f2 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt @@ -8,12 +8,14 @@ */ package com.nextcloud.talk.fullscreenfile +import android.content.ComponentName import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.MaterialTheme import androidx.core.content.FileProvider +import androidx.core.net.toUri import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.nextcloud.talk.BuildConfig @@ -22,8 +24,11 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.AccountUtils.canWeOpenFilesApp import com.nextcloud.talk.utils.Mimetype.TEXT_PREFIX_GENERIC import com.nextcloud.talk.utils.adjustUIForAPILevel35 +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID import java.io.File import javax.inject.Inject @@ -39,6 +44,10 @@ class FullScreenTextViewerActivity : AppCompatActivity() { val fileName = intent.getStringExtra("FILE_NAME").orEmpty() val isMarkdown = intent.getBooleanExtra("IS_MARKDOWN", false) + val fileId = intent.getStringExtra("FILE_ID").orEmpty() + val link = intent.getStringExtra("LINK") + val username = intent.getStringExtra("USERNAME").orEmpty() + val baseUrl = intent.getStringExtra("BASE_URL").orEmpty() val path = applicationContext.cacheDir.absolutePath + "/" + fileName val text = readFile(path) @@ -53,12 +62,36 @@ class FullScreenTextViewerActivity : AppCompatActivity() { text = text, isMarkdown = isMarkdown, onShare = { shareFile(path) }, - onSave = { showSaveDialog(fileName) } + onSave = { showSaveDialog(fileName) }, + onOpenInFilesApp = if (fileId.isNotEmpty()) { + { openInFilesApp(link, fileId, username, baseUrl) } + } else { + null + } ) } } } + private fun openInFilesApp(link: String?, fileId: String, username: String, baseUrl: String) { + val accountString = "$username@${baseUrl.replace("https://", "").replace("http://", "")}" + if (canWeOpenFilesApp(this, accountString)) { + val filesAppIntent = Intent(Intent.ACTION_VIEW, null) + val componentName = ComponentName( + getString(R.string.nc_import_accounts_from), + "com.owncloud.android.ui.activity.FileDisplayActivity" + ) + filesAppIntent.component = componentName + filesAppIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + filesAppIntent.setPackage(getString(R.string.nc_import_accounts_from)) + filesAppIntent.putExtra(KEY_ACCOUNT, accountString) + filesAppIntent.putExtra(KEY_FILE_ID, fileId) + startActivity(filesAppIntent) + } else if (!link.isNullOrEmpty()) { + startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) + } + } + private fun shareFile(path: String) { val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, File(path)) val shareIntent = Intent().apply { diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index a7a76d4c514..861a737598f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -97,6 +97,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { openOrDownloadFile( fileInfo, path, + link, mimetype, progressUi, openWhenDownloaded @@ -125,17 +126,19 @@ class FileViewerUtils(private val context: Context, private val user: User) { private fun openOrDownloadFile( fileInfo: FileInfo, path: String, + link: String?, mimetype: String?, progressUi: ProgressUi, openWhenDownloaded: Boolean ) { val file = File(context.cacheDir, fileInfo.fileName) if (file.exists()) { - openFileByMimetype(fileInfo.fileName, mimetype) + openFileByMimetype(fileInfo.fileName, mimetype, link, fileInfo.fileId) } else { downloadFileToCache( fileInfo, path, + link, mimetype, progressUi, openWhenDownloaded @@ -143,7 +146,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { } } - private fun openFileByMimetype(filename: String, mimetype: String?) { + private fun openFileByMimetype(filename: String, mimetype: String?, link: String? = null, fileId: String = "") { if (mimetype != null) { when (mimetype) { AUDIO_MPEG, @@ -161,7 +164,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { -> openImageView(filename, mimetype) TEXT_MARKDOWN, TEXT_PLAIN - -> openTextView(filename, mimetype) + -> openTextView(filename, mimetype, link, fileId) else -> openFileByExternalApp(filename, mimetype) } @@ -236,10 +239,14 @@ class FileViewerUtils(private val context: Context, private val user: User) { context.startActivity(fullScreenMediaIntent) } - private fun openTextView(filename: String, mimetype: String) { + private fun openTextView(filename: String, mimetype: String, link: String?, fileId: String) { val fullScreenTextViewerIntent = Intent(context, FullScreenTextViewerActivity::class.java) fullScreenTextViewerIntent.putExtra("FILE_NAME", filename) fullScreenTextViewerIntent.putExtra("IS_MARKDOWN", isMarkdown(mimetype)) + fullScreenTextViewerIntent.putExtra("FILE_ID", fileId) + fullScreenTextViewerIntent.putExtra("LINK", link) + fullScreenTextViewerIntent.putExtra("USERNAME", user.username) + fullScreenTextViewerIntent.putExtra("BASE_URL", user.baseUrl) context.startActivity(fullScreenTextViewerIntent) } @@ -265,6 +272,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { private fun downloadFileToCache( fileInfo: FileInfo, path: String, + link: String?, mimetype: String?, progressUi: ProgressUi, openWhenDownloaded: Boolean @@ -316,7 +324,9 @@ class FileViewerUtils(private val context: Context, private val user: User) { mimetype, workInfo!!, progressUi, - openWhenDownloaded + openWhenDownloaded, + link, + fileInfo.fileId ) } } @@ -326,7 +336,9 @@ class FileViewerUtils(private val context: Context, private val user: User) { mimetype: String?, workInfo: WorkInfo, progressUi: ProgressUi, - openWhenDownloaded: Boolean + openWhenDownloaded: Boolean, + link: String? = null, + fileId: String = "" ) { when (workInfo.state) { WorkInfo.State.RUNNING -> { @@ -341,7 +353,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { } WorkInfo.State.SUCCEEDED -> { if (progressUi.previewImage.isShown && openWhenDownloaded) { - openFileByMimetype(fileName, mimetype) + openFileByMimetype(fileName, mimetype, link, fileId) } else { Log.d( TAG, From eecf0399dc7a9c727c243d9fc431355cb57fca7d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 27 Feb 2026 13:57:34 +0100 Subject: [PATCH 3/7] fix(detekt): Fix detekt issues Signed-off-by: Andy Scherzinger --- .../com/nextcloud/talk/chat/OverflowMenu.kt | 4 +- .../fullscreenfile/FullScreenTextScreen.kt | 32 +++++----- .../FullScreenTextViewerActivity.kt | 16 ++--- .../adapters/SharedItemsViewHolder.kt | 12 ++-- .../nextcloud/talk/utils/FileViewerUtils.kt | 63 +++++++------------ 5 files changed, 54 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/OverflowMenu.kt b/app/src/main/java/com/nextcloud/talk/chat/OverflowMenu.kt index fc403f60b7a..fed5ae7824a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/OverflowMenu.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/OverflowMenu.kt @@ -35,8 +35,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import com.nextcloud.talk.R -data class MenuItemData(val title: String, val subtitle: String? = null, val icon: Int? = null, val onClick: () -> Unit) - @Composable fun OverflowMenu(anchor: View?, expanded: Boolean, items: List, onDismiss: () -> Unit) { if (!expanded) return @@ -110,6 +108,8 @@ fun DynamicMenuItem(item: MenuItemData) { } } +data class MenuItemData(val title: String, val subtitle: String? = null, val icon: Int? = null, val onClick: () -> Unit) + private fun View.boundsInWindow(): android.graphics.Rect { val location = IntArray(2) getLocationOnScreen(location) diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt index ada97f5c813..c0eeb72e1c9 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextScreen.kt @@ -34,19 +34,12 @@ import com.nextcloud.talk.components.StandardAppBar import io.noties.markwon.Markwon @Composable -fun FullScreenTextScreen( - title: String, - text: String, - isMarkdown: Boolean, - onShare: () -> Unit, - onSave: () -> Unit, - onOpenInFilesApp: (() -> Unit)? = null -) { +fun FullScreenTextScreen(title: String, text: String, isMarkdown: Boolean, actions: FullScreenTextActions) { val menuItems = buildList { - add(stringResource(R.string.share) to onShare) - add(stringResource(R.string.nc_save_message) to onSave) - if (onOpenInFilesApp != null) { - add(stringResource(R.string.open_in_files_app) to onOpenInFilesApp) + add(stringResource(R.string.share) to actions.onShare) + add(stringResource(R.string.nc_save_message) to actions.onSave) + if (actions.onOpenInFilesApp != null) { + add(stringResource(R.string.open_in_files_app) to actions.onOpenInFilesApp) } } @@ -86,6 +79,12 @@ fun FullScreenTextScreen( } } +data class FullScreenTextActions( + val onShare: () -> Unit, + val onSave: () -> Unit, + val onOpenInFilesApp: (() -> Unit)? = null +) + private const val PREVIEW_TEXT = """ # Heading @@ -104,8 +103,7 @@ private fun PreviewFullScreenTextScreenLight() { title = "notes.md", text = PREVIEW_TEXT, isMarkdown = false, - onShare = {}, - onSave = {} + actions = FullScreenTextActions(onShare = {}, onSave = {}) ) } } @@ -118,8 +116,7 @@ private fun PreviewFullScreenTextScreenDark() { title = "notes.md", text = PREVIEW_TEXT, isMarkdown = false, - onShare = {}, - onSave = {} + actions = FullScreenTextActions(onShare = {}, onSave = {}) ) } } @@ -132,8 +129,7 @@ private fun PreviewFullScreenTextScreenRtl() { title = "ملاحظات.md", text = "هذا نص تجريبي باللغة العربية لاختبار تخطيط الواجهة من اليمين إلى اليسار.", isMarkdown = false, - onShare = {}, - onSave = {} + actions = FullScreenTextActions(onShare = {}, onSave = {}) ) } } diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt index f0c954b66f2..dc70b75de1e 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt @@ -61,13 +61,15 @@ class FullScreenTextViewerActivity : AppCompatActivity() { title = fileName, text = text, isMarkdown = isMarkdown, - onShare = { shareFile(path) }, - onSave = { showSaveDialog(fileName) }, - onOpenInFilesApp = if (fileId.isNotEmpty()) { - { openInFilesApp(link, fileId, username, baseUrl) } - } else { - null - } + actions = FullScreenTextActions( + onShare = { shareFile(path) }, + onSave = { showSaveDialog(fileName) }, + onOpenInFilesApp = if (fileId.isNotEmpty()) { + { openInFilesApp(link, fileId, username, baseUrl) } + } else { + null + } + ) ) } } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt index f9538348622..c4abfdeb482 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt @@ -61,10 +61,14 @@ abstract class SharedItemsViewHolder( clickTarget.setOnClickListener { fileViewerUtils.openFile( - FileViewerUtils.FileInfo(item.id, item.name, item.fileSize), - item.path, - item.link, - item.mimeType, + FileViewerUtils.FileInfo( + item.id, + item.name, + item.fileSize, + item.path, + item.link, + item.mimeType + ), FileViewerUtils.ProgressUi( progressBar, null, diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 861a737598f..497e388fbe1 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -76,34 +76,23 @@ class FileViewerUtils(private val context: Context, private val user: User) { val fileSize = size.toLong() openFile( - FileInfo(fileId, fileName, fileSize), - path, - link, - mimetype, + FileInfo(fileId, fileName, fileSize, path, link, mimetype), progressUi, message.openWhenDownloaded ) } - fun openFile( - fileInfo: FileInfo, - path: String, - link: String?, - mimetype: String?, - progressUi: ProgressUi, - openWhenDownloaded: Boolean - ) { - if (isSupportedForInternalViewer(mimetype) || canBeHandledByExternalApp(mimetype, fileInfo.fileName)) { + fun openFile(fileInfo: FileInfo, progressUi: ProgressUi, openWhenDownloaded: Boolean) { + if (isSupportedForInternalViewer(fileInfo.mimetype) || + canBeHandledByExternalApp(fileInfo.mimetype, fileInfo.fileName) + ) { openOrDownloadFile( fileInfo, - path, - link, - mimetype, progressUi, openWhenDownloaded ) - } else if (!link.isNullOrEmpty()) { - openFileInFilesApp(link, fileInfo.fileId) + } else if (!fileInfo.link.isNullOrEmpty()) { + openFileInFilesApp(fileInfo.link, fileInfo.fileId) } else { Log.e( TAG, @@ -123,23 +112,13 @@ class FileViewerUtils(private val context: Context, private val user: User) { return intent.resolveActivity(context.packageManager) != null } - private fun openOrDownloadFile( - fileInfo: FileInfo, - path: String, - link: String?, - mimetype: String?, - progressUi: ProgressUi, - openWhenDownloaded: Boolean - ) { + private fun openOrDownloadFile(fileInfo: FileInfo, progressUi: ProgressUi, openWhenDownloaded: Boolean) { val file = File(context.cacheDir, fileInfo.fileName) if (file.exists()) { - openFileByMimetype(fileInfo.fileName, mimetype, link, fileInfo.fileId) + openFileByMimetype(fileInfo.fileName, fileInfo.mimetype, fileInfo.link, fileInfo.fileId) } else { downloadFileToCache( fileInfo, - path, - link, - mimetype, progressUi, openWhenDownloaded ) @@ -269,14 +248,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { } @SuppressLint("LongLogTag") - private fun downloadFileToCache( - fileInfo: FileInfo, - path: String, - link: String?, - mimetype: String?, - progressUi: ProgressUi, - openWhenDownloaded: Boolean - ) { + private fun downloadFileToCache(fileInfo: FileInfo, progressUi: ProgressUi, openWhenDownloaded: Boolean) { // check if download worker is already running val workers = WorkManager.getInstance(context).getWorkInfosByTag(fileInfo.fileId) try { @@ -307,7 +279,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { CapabilitiesUtil.getAttachmentFolder(user.capabilities!!.spreedCapability!!) ) .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileInfo.fileName) - .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) + .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, fileInfo.path) .putLong(DownloadFileToCacheWorker.KEY_FILE_SIZE, size) .build() @@ -321,11 +293,11 @@ class FileViewerUtils(private val context: Context, private val user: User) { .observeForever { workInfo: WorkInfo? -> updateViewsByProgress( fileInfo.fileName, - mimetype, + fileInfo.mimetype, workInfo!!, progressUi, openWhenDownloaded, - link, + fileInfo.link, fileInfo.fileId ) } @@ -412,7 +384,14 @@ class FileViewerUtils(private val context: Context, private val user: User) { data class ProgressUi(val progressBar: ProgressBar?, val messageText: EmojiTextView?, val previewImage: ImageView) - data class FileInfo(val fileId: String, val fileName: String, var fileSize: Long?) + data class FileInfo( + val fileId: String, + val fileName: String, + var fileSize: Long?, + val path: String, + val link: String?, + val mimetype: String? + ) companion object { private val TAG = FileViewerUtils::class.simpleName From 9ba2b7e0d73c3f32b0ebc7fb9d15b3ab934668a7 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 27 Feb 2026 20:09:21 +0100 Subject: [PATCH 4/7] feat: Migrate FullSDcreen Image to Composable and have imporved top/bottom gradients for any bar when viewing the image Signed-off-by: Andy Scherzinger --- .../talk/components/StandardAppBar.kt | 9 +- .../fullscreenfile/FullScreenImageActivity.kt | 202 ++++++------------ .../fullscreenfile/FullScreenImageScreen.kt | 185 ++++++++++++++++ .../res/layout/activity_full_screen_image.xml | 39 ---- 4 files changed, 260 insertions(+), 175 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt delete mode 100644 app/src/main/res/layout/activity_full_screen_image.xml diff --git a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt index b06a68d38b3..690605d705c 100644 --- a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt +++ b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt @@ -17,6 +17,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,13 +34,18 @@ import com.nextcloud.talk.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun StandardAppBar(title: String, menuItems: List Unit>>?) { +fun StandardAppBar( + title: String, + menuItems: List Unit>>?, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors() +) { val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher var expanded by remember { mutableStateOf(false) } TopAppBar( title = { Text(text = title) }, + colors = colors, navigationIcon = { IconButton( onClick = { backDispatcher?.onBackPressed() } diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt index 17b6e0b01b3..def7a442c65 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt @@ -12,159 +12,92 @@ package com.nextcloud.talk.fullscreenfile import android.content.Intent import android.os.Bundle -import android.util.Log -import com.nextcloud.talk.ui.SwipeToCloseLayout -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup.MarginLayoutParams +import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.FileProvider -import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.databinding.ActivityFullScreenImageBinding +import com.nextcloud.talk.ui.SwipeToCloseLayout import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment -import com.nextcloud.talk.utils.BitmapShrinker +import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC -import pl.droidsonroids.gif.GifDrawable import java.io.File +import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class FullScreenImageActivity : AppCompatActivity() { - lateinit var binding: ActivityFullScreenImageBinding - private lateinit var windowInsetsController: WindowInsetsControllerCompat - private lateinit var path: String - private var showFullscreen = false - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_preview, menu) - return true - } - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - - R.id.share -> { - val shareUri = FileProvider.getUriForFile( - this, - BuildConfig.APPLICATION_ID, - File(path) - ) - - val shareIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, shareUri) - type = IMAGE_PREFIX_GENERIC - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) - - true - } + @Inject + lateinit var viewThemeUtils: ViewThemeUtils - R.id.save -> { - val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance( - intent.getStringExtra("FILE_NAME").toString() - ) - saveFragment.show( - supportFragmentManager, - SaveToStorageDialogFragment.TAG - ) - true - } - - else -> { - super.onOptionsItemSelected(item) - } - } + private lateinit var windowInsetsController: WindowInsetsControllerCompat + private lateinit var path: String + private lateinit var fileName: String + private lateinit var swipeToCloseLayout: SwipeToCloseLayout + private var showFullscreen by mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - binding = ActivityFullScreenImageBinding.inflate(layoutInflater) - setContentView(binding.root) - - setSupportActionBar(binding.imageviewToolbar) - WindowCompat.setDecorFitsSystemWindows(window, false) - initWindowInsetsController() - applyWindowInsets() - binding.photoView.setOnPhotoTapListener { _, _, _ -> - toggleFullscreen() - } - binding.photoView.setOnOutsidePhotoTapListener { - toggleFullscreen() - } - binding.gifView.setOnClickListener { - toggleFullscreen() - } - - // Enable enlarging the image more than default 3x maximumScale. - // Medium scale adapted to make double-tap behaviour more consistent. - binding.photoView.maximumScale = MAX_SCALE - binding.photoView.mediumScale = MEDIUM_SCALE - - val fileName = intent.getStringExtra("FILE_NAME") + fileName = intent.getStringExtra("FILE_NAME").orEmpty() val isGif = intent.getBooleanExtra("IS_GIF", false) - - supportActionBar?.title = fileName - supportActionBar?.setDisplayHomeAsUpEnabled(true) - path = applicationContext.cacheDir.absolutePath + "/" + fileName - if (isGif) { - binding.photoView.visibility = View.INVISIBLE - binding.gifView.visibility = View.VISIBLE - val gifFromUri = GifDrawable(path) - binding.gifView.setImageDrawable(gifFromUri) - } else { - binding.gifView.visibility = View.INVISIBLE - binding.photoView.visibility = View.VISIBLE - displayImage(path) - } - binding.swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + ) + initWindowInsetsController() + + swipeToCloseLayout = SwipeToCloseLayout(this) + swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { override fun onSwipeToClose() { finish() } }) - } - private fun displayImage(path: String) { - val displayMetrics = applicationContext.resources.displayMetrics - val doubleScreenWidth = displayMetrics.widthPixels * 2 - val doubleScreenHeight = displayMetrics.heightPixels * 2 - - val bitmap = BitmapShrinker.shrinkBitmap(path, doubleScreenWidth, doubleScreenHeight) - - if (bitmap == null) { - Log.e(TAG, "bitmap could not be decoded from path: $path") - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - return + val composeView = ComposeView(this).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = viewThemeUtils.getColorScheme(this@FullScreenImageActivity) + MaterialTheme(colorScheme = colorScheme) { + FullScreenImageScreen( + title = fileName, + isGif = isGif, + imagePath = path, + showFullscreen = showFullscreen, + actions = FullScreenImageActions( + onShare = { shareFile() }, + onSave = { showSaveDialog() }, + onToggleFullscreen = { toggleFullscreen() }, + onBitmapError = { showBitmapError() } + ) + ) + } + } } - val bitmapSize: Int = bitmap.byteCount - - // info that 100MB is the limit comes from https://stackoverflow.com/a/53334563 - if (bitmapSize > HUNDRED_MB) { - Log.e(TAG, "bitmap will be too large to display. It won't be displayed to avoid RuntimeException") - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } else { - binding.photoView.setImageBitmap(bitmap) - } + swipeToCloseLayout.addView( + composeView, + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + ) + setContentView(swipeToCloseLayout) } private fun toggleFullscreen() { @@ -183,30 +116,29 @@ class FullScreenImageActivity : AppCompatActivity() { private fun enterImmersiveMode() { windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - supportActionBar?.hide() } private fun exitImmersiveMode() { windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) - supportActionBar?.show() } - private fun applyWindowInsets() { - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> - val insets = - windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) - binding.imageviewToolbar.updateLayoutParams { - topMargin = insets.top - } - binding.imageviewToolbar.updatePadding(left = insets.left, right = insets.right) - WindowInsetsCompat.CONSUMED + private fun shareFile() { + val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, File(path)) + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, shareUri) + type = IMAGE_PREFIX_GENERIC + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) + } + + private fun showSaveDialog() { + val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(fileName) + saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) } - companion object { - private const val TAG = "FullScreenImageActivity" - private const val HUNDRED_MB = 100 * 1024 * 1024 - private const val MAX_SCALE = 6.0f - private const val MEDIUM_SCALE = 2.45f + private fun showBitmapError() { + Snackbar.make(swipeToCloseLayout, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } } diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt new file mode 100644 index 00000000000..21f91b44410 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt @@ -0,0 +1,185 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-FileCopyrightText: 2021 Marcel Hibbe + * SPDX-FileCopyrightText: 2021 Dariusz Olszewski + * SPDX-FileCopyrightText: 2026 Andy Scherzinger + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.fullscreenfile + +import android.content.res.Configuration +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import com.github.chrisbanes.photoview.PhotoView +import com.nextcloud.talk.R +import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.utils.BitmapShrinker +import pl.droidsonroids.gif.GifDrawable +import pl.droidsonroids.gif.GifImageView + +private const val TAG = "FullScreenImageScreen" +private const val MAX_SCALE = 6.0f +private const val MEDIUM_SCALE = 2.45f +private const val HUNDRED_MB = 100 * 1024 * 1024 +private const val TOOLBAR_ALPHA = 0.5f + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FullScreenImageScreen( + title: String, + isGif: Boolean, + imagePath: String, + showFullscreen: Boolean, + actions: FullScreenImageActions +) { + val toolbarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White + ) + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + if (isGif) { + GifView(imagePath = imagePath, onToggleFullscreen = actions.onToggleFullscreen) + } else { + PhotoImageView( + imagePath = imagePath, + onToggleFullscreen = actions.onToggleFullscreen, + onBitmapError = actions.onBitmapError + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.navigationBars) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = TOOLBAR_ALPHA)) + ) + ) + .align(Alignment.BottomCenter) + ) + + if (!showFullscreen) { + val menuItems = buildList { + add(stringResource(R.string.share) to actions.onShare) + add(stringResource(R.string.nc_save_message) to actions.onSave) + } + Box { + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf(Color.Black.copy(alpha = TOOLBAR_ALPHA), Color.Transparent) + ) + ) + ) + StandardAppBar(title = title, menuItems = menuItems, colors = toolbarColors) + } + } + } +} + +@Composable +private fun GifView(imagePath: String, onToggleFullscreen: () -> Unit) { + AndroidView( + factory = { ctx -> + GifImageView(ctx).apply { + setImageDrawable(GifDrawable(imagePath)) + setOnClickListener { onToggleFullscreen() } + } + }, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +private fun PhotoImageView(imagePath: String, onToggleFullscreen: () -> Unit, onBitmapError: () -> Unit) { + AndroidView( + factory = { ctx -> + PhotoView(ctx).apply { + maximumScale = MAX_SCALE + mediumScale = MEDIUM_SCALE + setOnPhotoTapListener { _, _, _ -> onToggleFullscreen() } + setOnOutsidePhotoTapListener { onToggleFullscreen() } + val displayMetrics = ctx.resources.displayMetrics + val bitmap = BitmapShrinker.shrinkBitmap( + imagePath, + displayMetrics.widthPixels * 2, + displayMetrics.heightPixels * 2 + ) + when { + bitmap == null -> { + Log.e(TAG, "bitmap could not be decoded from path: $imagePath") + onBitmapError() + } + bitmap.byteCount > HUNDRED_MB -> { + Log.e(TAG, "bitmap too large to display, skipping to avoid RuntimeException") + onBitmapError() + } + else -> setImageBitmap(bitmap) + } + } + }, + modifier = Modifier.fillMaxSize() + ) +} + +data class FullScreenImageActions( + val onShare: () -> Unit, + val onSave: () -> Unit, + val onToggleFullscreen: () -> Unit, + val onBitmapError: () -> Unit +) + +@Preview(name = "Light", showBackground = true) +@Composable +private fun PreviewFullScreenImageLight() { + MaterialTheme(colorScheme = lightColorScheme()) { + FullScreenImageScreen( + title = "image.jpg", + isGif = false, + imagePath = "", + showFullscreen = false, + actions = FullScreenImageActions(onShare = {}, onSave = {}, onToggleFullscreen = {}, onBitmapError = {}) + ) + } +} + +@Preview(name = "Dark - RTL Arabic", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, locale = "ar") +@Composable +private fun PreviewFullScreenImageDarkRtl() { + MaterialTheme(colorScheme = darkColorScheme()) { + FullScreenImageScreen( + title = "صورة.jpg", + isGif = false, + imagePath = "", + showFullscreen = false, + actions = FullScreenImageActions(onShare = {}, onSave = {}, onToggleFullscreen = {}, onBitmapError = {}) + ) + } +} diff --git a/app/src/main/res/layout/activity_full_screen_image.xml b/app/src/main/res/layout/activity_full_screen_image.xml deleted file mode 100644 index 4c98c772c60..00000000000 --- a/app/src/main/res/layout/activity_full_screen_image.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - From 20f9eed92b0d931f618bf18bb9df395eb85455ab Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 27 Feb 2026 20:24:34 +0100 Subject: [PATCH 5/7] fix: Make top bar title single line and ellipsized Signed-off-by: Andy Scherzinger --- .../main/java/com/nextcloud/talk/components/StandardAppBar.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt index 690605d705c..d6539b94742 100644 --- a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt +++ b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -44,7 +45,7 @@ fun StandardAppBar( var expanded by remember { mutableStateOf(false) } TopAppBar( - title = { Text(text = title) }, + title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, colors = colors, navigationIcon = { IconButton( From 5a1f596ecb08b8b4cece066e1ce95cfef4a9711c Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 27 Feb 2026 21:49:32 +0100 Subject: [PATCH 6/7] feat: Migrate fullscreen media player Signed-off-by: Andy Scherzinger --- .../fullscreenfile/FullScreenMediaActivity.kt | 177 +++++--------- .../fullscreenfile/FullScreenMediaScreen.kt | 216 ++++++++++++++++++ .../res/layout/activity_full_screen_media.xml | 33 --- app/src/main/res/menu/menu_preview.xml | 15 -- app/src/main/res/values-v27/styles.xml | 2 +- app/src/main/res/values/styles.xml | 2 +- detekt.yml | 2 +- 7 files changed, 279 insertions(+), 168 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt delete mode 100644 app/src/main/res/layout/activity_full_screen_media.xml delete mode 100644 app/src/main/res/menu/menu_preview.xml diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt index 0ccf3e64774..1d15732e431 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt @@ -12,34 +12,33 @@ package com.nextcloud.talk.fullscreenfile import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager import android.widget.FrameLayout +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.FileProvider import androidx.core.net.toUri -import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.marginBottom -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding import androidx.fragment.app.DialogFragment import androidx.media3.common.AudioAttributes import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView import autodagger.AutoInjector import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.databinding.ActivityFullScreenMediaBinding import com.nextcloud.talk.ui.SwipeToCloseLayout import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC @@ -47,102 +46,60 @@ import java.io.File @AutoInjector(NextcloudTalkApplication::class) class FullScreenMediaActivity : AppCompatActivity() { - lateinit var binding: ActivityFullScreenMediaBinding private lateinit var path: String - private var player: ExoPlayer? = null - + private lateinit var fileName: String + private var player: ExoPlayer? by mutableStateOf(null) private var playWhenReadyState: Boolean = true private var playBackPosition: Long = 0L private lateinit var windowInsetsController: WindowInsetsControllerCompat - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_preview, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - - R.id.share -> { - val shareUri = FileProvider.getUriForFile( - this, - BuildConfig.APPLICATION_ID, - File(path) - ) - - val shareIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, shareUri) - type = VIDEO_PREFIX_GENERIC - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) - - true - } - - R.id.save -> { - val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance( - intent.getStringExtra("FILE_NAME").toString() - ) - saveFragment.show( - supportFragmentManager, - SaveToStorageDialogFragment.TAG - ) - true - } - - else -> { - super.onOptionsItemSelected(item) - } - } - - @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) - val fileName = intent.getStringExtra("FILE_NAME") - val isAudioOnly = intent.getBooleanExtra("AUDIO_ONLY", false) + fileName = intent.getStringExtra("FILE_NAME").orEmpty() + val isAudioOnly = intent.getBooleanExtra("AUDIO_ONLY", false) path = applicationContext.cacheDir.absolutePath + "/" + fileName - binding = ActivityFullScreenMediaBinding.inflate(layoutInflater) - setContentView(binding.root) - - setSupportActionBar(binding.mediaviewToolbar) - supportActionBar?.title = fileName - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - - binding.playerView.showController() - if (isAudioOnly) { - binding.playerView.controllerShowTimeoutMs = 0 - } - - initWindowInsetsController() - applyWindowInsets() - - binding.playerView.setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { v -> - if (v != 0) { - enterImmersiveMode() - } else { - exitImmersiveMode() - } - } + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) ) + initWindowInsetsController() - binding.swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { + val swipeToCloseLayout = SwipeToCloseLayout(this) + swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { override fun onSwipeToClose() { finish() } }) + + val composeView = ComposeView(this).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + FullScreenMediaScreen( + title = fileName, + player = player, + isAudioOnly = isAudioOnly, + actions = FullScreenMediaActions( + onShare = { shareFile() }, + onSave = { showSaveDialog() }, + onEnterImmersive = { enterImmersiveMode() }, + onExitImmersive = { exitImmersiveMode() } + ) + ) + } + } + } + + swipeToCloseLayout.addView( + composeView, + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + ) + setContentView(swipeToCloseLayout) + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } override fun onStart() { @@ -156,12 +113,12 @@ class FullScreenMediaActivity : AppCompatActivity() { releasePlayer() } + @OptIn(UnstableApi::class) private fun initializePlayer() { player = ExoPlayer.Builder(applicationContext) .setAudioAttributes(AudioAttributes.DEFAULT, true) .setHandleAudioBecomingNoisy(true) .build() - binding.playerView.player = player } private fun preparePlayer() { @@ -190,39 +147,25 @@ class FullScreenMediaActivity : AppCompatActivity() { private fun enterImmersiveMode() { windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - supportActionBar?.hide() } private fun exitImmersiveMode() { windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) - supportActionBar?.show() } - @OptIn(UnstableApi::class) - private fun applyWindowInsets() { - val playerView = binding.playerView - val exoControls = playerView.findViewById(R.id.exo_bottom_bar) - val exoProgress = playerView.findViewById(R.id.exo_progress) - val progressBottomMargin = exoProgress.marginBottom - - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> - val insets = windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type - .displayCutout() - ) - binding.mediaviewToolbar.updateLayoutParams { - topMargin = insets.top - } - exoControls.updateLayoutParams { - bottomMargin = insets.bottom - } - exoProgress.updateLayoutParams { - bottomMargin = insets.bottom + progressBottomMargin - } - exoControls.updatePadding(left = insets.left, right = insets.right) - exoProgress.updatePadding(left = insets.left, right = insets.right) - binding.mediaviewToolbar.updatePadding(left = insets.left, right = insets.right) - WindowInsetsCompat.CONSUMED + private fun shareFile() { + val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, File(path)) + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, shareUri) + type = VIDEO_PREFIX_GENERIC + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) + } + + private fun showSaveDialog() { + val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(fileName) + saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) } } diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt new file mode 100644 index 00000000000..e5ce478b073 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt @@ -0,0 +1,216 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2021 Andy Scherzinger + * SPDX-FileCopyrightText: 2021 Marcel Hibbe + * SPDX-FileCopyrightText: 2023 Parneet Singh + * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham + * SPDX-FileCopyrightText: 2026 Enrique López-Mañas + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.fullscreenfile + +import android.content.res.Configuration +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView +import com.nextcloud.talk.R +import com.nextcloud.talk.components.StandardAppBar + +private const val TOOLBAR_ALPHA = 0.5f + +@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) +@Composable +fun FullScreenMediaScreen(title: String, player: ExoPlayer?, isAudioOnly: Boolean, actions: FullScreenMediaActions) { + val toolbarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White + ) + + var showToolbar by remember { mutableStateOf(true) } + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + MediaPlayerView( + player = player, + isAudioOnly = isAudioOnly, + onControllerVisible = { + showToolbar = true + actions.onExitImmersive() + }, + onControllerHidden = { + showToolbar = false + actions.onEnterImmersive() + } + ) + + BottomGradient(modifier = Modifier.align(Alignment.BottomCenter)) + + if (showToolbar) { + ToolbarOverlay(title = title, toolbarColors = toolbarColors, actions = actions) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun MediaPlayerView( + player: ExoPlayer?, + isAudioOnly: Boolean, + onControllerVisible: () -> Unit, + onControllerHidden: () -> Unit +) { + if (LocalInspectionMode.current) { + Box(modifier = Modifier.fillMaxSize()) + return + } + + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val bottomPx = WindowInsets.systemBars.getBottom(density) + val leftPx = WindowInsets.systemBars.getLeft(density, layoutDirection) + val rightPx = WindowInsets.systemBars.getRight(density, layoutDirection) + val originalProgressMarginBottom = remember { intArrayOf(-1) } + + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + showController() + if (isAudioOnly) { + controllerShowTimeoutMs = 0 + } + setControllerVisibilityListener( + PlayerView.ControllerVisibilityListener { visibility -> + if (visibility == View.VISIBLE) onControllerVisible() else onControllerHidden() + } + ) + } + }, + update = { playerView -> + playerView.player = player + val exoControls = playerView.findViewById(R.id.exo_bottom_bar) + val exoProgress = playerView.findViewById(R.id.exo_progress) + exoControls?.apply { + updateLayoutParams { bottomMargin = bottomPx } + updatePadding(left = leftPx, right = rightPx) + } + exoProgress?.apply { + if (originalProgressMarginBottom[0] < 0) { + originalProgressMarginBottom[0] = + (layoutParams as? MarginLayoutParams)?.bottomMargin ?: 0 + } + updateLayoutParams { + bottomMargin = bottomPx + originalProgressMarginBottom[0] + } + updatePadding(left = leftPx, right = rightPx) + } + }, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +private fun BottomGradient(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.navigationBars) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = TOOLBAR_ALPHA)) + ) + ) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ToolbarOverlay(title: String, toolbarColors: TopAppBarColors, actions: FullScreenMediaActions) { + val menuItems = buildList { + add(stringResource(R.string.share) to actions.onShare) + add(stringResource(R.string.nc_save_message) to actions.onSave) + } + Box { + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf(Color.Black.copy(alpha = TOOLBAR_ALPHA), Color.Transparent) + ) + ) + ) + StandardAppBar(title = title, menuItems = menuItems, colors = toolbarColors) + } +} + +data class FullScreenMediaActions( + val onShare: () -> Unit, + val onSave: () -> Unit, + val onEnterImmersive: () -> Unit, + val onExitImmersive: () -> Unit +) + +@Preview(name = "Light", showBackground = true) +@Composable +private fun PreviewFullScreenMediaLight() { + MaterialTheme(colorScheme = lightColorScheme()) { + FullScreenMediaScreen( + title = "video.mp4", + player = null, + isAudioOnly = false, + actions = FullScreenMediaActions(onShare = {}, onSave = {}, onEnterImmersive = {}, onExitImmersive = {}) + ) + } +} + +@Preview(name = "Dark - RTL Arabic", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, locale = "ar") +@Composable +private fun PreviewFullScreenMediaDarkRtlArabic() { + MaterialTheme(colorScheme = darkColorScheme()) { + FullScreenMediaScreen( + title = "فيديو.mp4", + player = null, + isAudioOnly = false, + actions = FullScreenMediaActions(onShare = {}, onSave = {}, onEnterImmersive = {}, onExitImmersive = {}) + ) + } +} diff --git a/app/src/main/res/layout/activity_full_screen_media.xml b/app/src/main/res/layout/activity_full_screen_media.xml deleted file mode 100644 index cbf8f6f8753..00000000000 --- a/app/src/main/res/layout/activity_full_screen_media.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_preview.xml b/app/src/main/res/menu/menu_preview.xml deleted file mode 100644 index 2c1b57a40fb..00000000000 --- a/app/src/main/res/menu/menu_preview.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml index 47933e7dd04..e0c258e7872 100644 --- a/app/src/main/res/values-v27/styles.xml +++ b/app/src/main/res/values-v27/styles.xml @@ -21,7 +21,7 @@