diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatter.kt new file mode 100644 index 00000000..d8594627 --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatter.kt @@ -0,0 +1,50 @@ +package eu.qsfera.android.presentation.sharing.shares + +import eu.qsfera.android.domain.sharing.shares.model.OCShare +import eu.qsfera.android.lib.resources.shares.RemoteShare + +internal object PublicShareStatusFormatter { + + fun statusFor(share: OCShare): PublicShareStatus = + PublicShareStatus( + accessMode = accessModeFor(share.permissions), + isPasswordProtected = share.isPasswordProtected, + expirationDateInMillis = share.expirationDate.takeIf { it > 0 } + ) + + private fun accessModeFor(permissions: Int): PublicShareAccessMode { + val canRead = permissions hasPermission RemoteShare.READ_PERMISSION_FLAG + val canCreate = permissions hasPermission RemoteShare.CREATE_PERMISSION_FLAG + val canUpdate = permissions hasPermission RemoteShare.UPDATE_PERMISSION_FLAG + val canDelete = permissions hasPermission RemoteShare.DELETE_PERMISSION_FLAG + val hasCustomFlags = permissions and PUBLIC_LINK_ACCESS_MASK.inv() != 0 + + return when { + hasCustomFlags -> PublicShareAccessMode.CUSTOM + canCreate && !canRead && !canUpdate && !canDelete -> PublicShareAccessMode.UPLOAD_ONLY + canRead && canCreate && canUpdate && canDelete -> PublicShareAccessMode.READ_AND_WRITE + canRead && !canCreate && !canUpdate && !canDelete -> PublicShareAccessMode.READ_ONLY + else -> PublicShareAccessMode.CUSTOM + } + } + + private infix fun Int.hasPermission(permissionFlag: Int): Boolean = this and permissionFlag == permissionFlag + + private const val PUBLIC_LINK_ACCESS_MASK = RemoteShare.READ_PERMISSION_FLAG or + RemoteShare.CREATE_PERMISSION_FLAG or + RemoteShare.UPDATE_PERMISSION_FLAG or + RemoteShare.DELETE_PERMISSION_FLAG +} + +internal data class PublicShareStatus( + val accessMode: PublicShareAccessMode, + val isPasswordProtected: Boolean, + val expirationDateInMillis: Long? +) + +internal enum class PublicShareAccessMode { + READ_ONLY, + READ_AND_WRITE, + UPLOAD_ONLY, + CUSTOM, +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/SharePublicLinkListAdapter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/SharePublicLinkListAdapter.kt index f55f2736..97518e1f 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/SharePublicLinkListAdapter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/SharePublicLinkListAdapter.kt @@ -30,7 +30,10 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import eu.qsfera.android.databinding.SharePublicLinkItemBinding import eu.qsfera.android.domain.sharing.shares.model.OCShare +import eu.qsfera.android.R import eu.qsfera.android.utils.PreferenceUtils +import java.text.DateFormat +import java.util.Date /** * Adapter to show a list of public links @@ -42,8 +45,6 @@ class SharePublicLinkListAdapter( private val listener: SharePublicLinkAdapterListener ) : ArrayAdapter(mContext, resource) { - private lateinit var binding: SharePublicLinkItemBinding - init { publicLinks = publicLinks.sortedBy { it.name } } @@ -55,8 +56,8 @@ class SharePublicLinkListAdapter( override fun getItemId(position: Int): Long = 0 override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val inflator = mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - binding = SharePublicLinkItemBinding.inflate(inflator).apply { + val inflator = LayoutInflater.from(mContext) + val binding = SharePublicLinkItemBinding.inflate(inflator, parent, false).apply { root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext) } @@ -65,6 +66,7 @@ class SharePublicLinkListAdapter( // If there's no name, set the token as name binding.publicLinkName.text = if (share.name.isNullOrEmpty()) share.token else share.name + binding.publicLinkDetails.text = formatPublicLinkDetails(share) // bind listener to get link binding.getPublicLinkButton.setOnClickListener { listener.copyOrSendPublicLink(publicLinks[position]) } @@ -79,6 +81,34 @@ class SharePublicLinkListAdapter( return binding.root } + private fun formatPublicLinkDetails(share: OCShare): String { + val status = PublicShareStatusFormatter.statusFor(share) + val expirationText = status.expirationDateInMillis?.let { + mContext.getString( + R.string.share_public_link_status_expires, + DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) + ) + } ?: mContext.getString(R.string.share_public_link_status_no_expiration) + + return listOf( + mContext.getString(status.accessMode.stringResId), + if (status.isPasswordProtected) { + mContext.getString(R.string.share_public_link_status_password_protected) + } else { + mContext.getString(R.string.share_public_link_status_no_password) + }, + expirationText + ).joinToString(separator = " • ") + } + + private val PublicShareAccessMode.stringResId: Int + get() = when (this) { + PublicShareAccessMode.READ_ONLY -> R.string.share_public_link_status_read_only + PublicShareAccessMode.READ_AND_WRITE -> R.string.share_public_link_status_read_and_write + PublicShareAccessMode.UPLOAD_ONLY -> R.string.share_public_link_status_upload_only + PublicShareAccessMode.CUSTOM -> R.string.share_public_link_status_custom + } + interface SharePublicLinkAdapterListener { fun copyOrSendPublicLink(share: OCShare) diff --git a/Android/qsferaApp/src/main/res/layout/share_public_link_item.xml b/Android/qsferaApp/src/main/res/layout/share_public_link_item.xml index 1cf75a05..d5da49c6 100644 --- a/Android/qsferaApp/src/main/res/layout/share_public_link_item.xml +++ b/Android/qsferaApp/src/main/res/layout/share_public_link_item.xml @@ -16,7 +16,7 @@ --> - + android:orientation="vertical"> + + + + + + Скачивание / Просмотр Скачивание / Просмотр / Закачка Только закачка (перетащить файл сюда) + Скачивание / просмотр + Скачивание / просмотр / загрузка + Только загрузка + Особые права + Защищено паролем + Без пароля + Действует до %1$s + Без срока действия Получить ссылку Поделиться с Поделиться с %1$s diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index 324e5d88..da90b72c 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -664,6 +664,14 @@ Download / View Download/View/Upload Upload Only (File Drop) + Download / View + Download / View / Upload + Upload only + Custom access + Password protected + No password + Expires %1$s + No expiration Get link Share with Share with %1$s diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatterTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatterTest.kt new file mode 100644 index 00000000..f1659632 --- /dev/null +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/sharing/shares/PublicShareStatusFormatterTest.kt @@ -0,0 +1,66 @@ +package eu.qsfera.android.presentation.sharing.shares + +import eu.qsfera.android.lib.resources.shares.RemoteShare +import eu.qsfera.android.testutil.OC_PUBLIC_SHARE +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PublicShareStatusFormatterTest { + + @Test + fun `statusFor reports read-only public links without password or expiration`() { + val share = OC_PUBLIC_SHARE.copy( + permissions = RemoteShare.READ_PERMISSION_FLAG, + shareWith = "", + expirationDate = 0 + ) + + val status = PublicShareStatusFormatter.statusFor(share) + + assertEquals(PublicShareAccessMode.READ_ONLY, status.accessMode) + assertFalse(status.isPasswordProtected) + assertNull(status.expirationDateInMillis) + } + + @Test + fun `statusFor reports password-protected read-write public links with expiration`() { + val expirationDate = 1_556_575_200_000L + val share = OC_PUBLIC_SHARE.copy( + permissions = RemoteShare.READ_PERMISSION_FLAG or + RemoteShare.CREATE_PERMISSION_FLAG or + RemoteShare.UPDATE_PERMISSION_FLAG or + RemoteShare.DELETE_PERMISSION_FLAG, + shareWith = "password-placeholder", + expirationDate = expirationDate + ) + + val status = PublicShareStatusFormatter.statusFor(share) + + assertEquals(PublicShareAccessMode.READ_AND_WRITE, status.accessMode) + assertTrue(status.isPasswordProtected) + assertEquals(expirationDate, status.expirationDateInMillis) + } + + @Test + fun `statusFor reports upload-only public links`() { + val share = OC_PUBLIC_SHARE.copy(permissions = RemoteShare.CREATE_PERMISSION_FLAG) + + val status = PublicShareStatusFormatter.statusFor(share) + + assertEquals(PublicShareAccessMode.UPLOAD_ONLY, status.accessMode) + } + + @Test + fun `statusFor reports custom public links for non-standard permission sets`() { + val share = OC_PUBLIC_SHARE.copy( + permissions = RemoteShare.READ_PERMISSION_FLAG or RemoteShare.SHARE_PERMISSION_FLAG + ) + + val status = PublicShareStatusFormatter.statusFor(share) + + assertEquals(PublicShareAccessMode.CUSTOM, status.accessMode) + } +} diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 47dc9008..1d3408ca 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -37,6 +37,7 @@ Date: 2026-06-08. - Android video backup settings now label the Wi-Fi-only control as "Never use mobile data for videos" and explain that turning it off enables metered-network limits and roaming controls. - Android photo/video backup settings now show a compact overall backup status: enabled state, selected phone-folder count, network/roaming/charging conditions, and last backup scan. - Android auto-upload now stores per-source folder sync cursors in Room schema version `52`, keeps the legacy `lastSyncTimestamp` as a migration fallback, starts newly added source folders at timestamp `0`, and preserves per-source cursors when a metered run stops at the daily mobile-data limit. `:qsferaDomain:testDebugUnitTest --tests "eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfigurationTest"`, `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.workers.AutomaticUploadsWorkerTest"`, and `:qsferaData:compileDebugKotlin` passed on 2026-06-10. +- Android public-link rows now show the effective access mode, password state, and expiration state directly in the share list, so users can review link safety without reopening each link. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.sharing.shares.PublicShareStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` passed on 2026-06-10. - Server `qsfera_full` compose files now require explicit admin, Keycloak, and S3/MinIO secrets instead of demo defaults. - Server `qsfera_full/env.production.example` provides a tracked production-safe starting point with TLS validation enabled and demo users disabled. - Server `qsfera_full/validate-production-env.sh` checks production `.env` invariants before compose validation: TLS validation enabled, demo users disabled, pinned production image, required domains, required secrets, and no public-production MinIO.