Show Android public link safety status

This commit is contained in:
Курнат Андрей
2026-06-10 07:51:01 +03:00
parent abdbf47789
commit 89a8f32649
7 changed files with 191 additions and 12 deletions
@@ -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,
}
@@ -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<OCShare>(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)
@@ -16,7 +16,7 @@
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
@@ -25,8 +25,7 @@
android:orientation="horizontal"
android:weightSum="1">
<TextView
android:id="@+id/publicLinkName"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
@@ -34,11 +33,28 @@
android:layout_marginStart="@dimen/standard_half_margin"
android:layout_marginTop="@dimen/standard_half_margin"
android:layout_weight="1"
android:ellipsize="middle"
android:singleLine="true"
android:text="@string/username"
android:textColor="@color/textColor"
android:textSize="@dimen/two_line_primary_text_size" />
android:orientation="vertical">
<TextView
android:id="@+id/publicLinkName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
android:text="@string/username"
android:textColor="@color/textColor"
android:textSize="@dimen/two_line_primary_text_size" />
<TextView
android:id="@+id/publicLinkDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/list_item_lastmod_and_filesize_text"
android:textSize="@dimen/two_line_secondary_text_size" />
</LinearLayout>
<ImageButton
android:id="@+id/getPublicLinkButton"
@@ -609,6 +609,14 @@
<string name="share_via_link_edit_permission_read_only_label">Скачивание / Просмотр</string>
<string name="share_via_link_edit_permission_read_and_write_label">Скачивание / Просмотр / Закачка</string>
<string name="share_via_link_edit_permission_upload_only_label">Только закачка (перетащить файл сюда)</string>
<string name="share_public_link_status_read_only">Скачивание / просмотр</string>
<string name="share_public_link_status_read_and_write">Скачивание / просмотр / загрузка</string>
<string name="share_public_link_status_upload_only">Только загрузка</string>
<string name="share_public_link_status_custom">Особые права</string>
<string name="share_public_link_status_password_protected">Защищено паролем</string>
<string name="share_public_link_status_no_password">Без пароля</string>
<string name="share_public_link_status_expires">Действует до %1$s</string>
<string name="share_public_link_status_no_expiration">Без срока действия</string>
<string name="share_get_public_link_button">Получить ссылку</string>
<string name="share_with_title">Поделиться с</string>
<string name="share_with_edit_title">Поделиться с %1$s</string>
@@ -664,6 +664,14 @@
<string name="share_via_link_edit_permission_read_only_label">Download / View</string>
<string name="share_via_link_edit_permission_read_and_write_label">Download/View/Upload</string>
<string name="share_via_link_edit_permission_upload_only_label">Upload Only (File Drop)</string>
<string name="share_public_link_status_read_only">Download / View</string>
<string name="share_public_link_status_read_and_write">Download / View / Upload</string>
<string name="share_public_link_status_upload_only">Upload only</string>
<string name="share_public_link_status_custom">Custom access</string>
<string name="share_public_link_status_password_protected">Password protected</string>
<string name="share_public_link_status_no_password">No password</string>
<string name="share_public_link_status_expires">Expires %1$s</string>
<string name="share_public_link_status_no_expiration">No expiration</string>
<string name="share_get_public_link_button">Get link</string>
<string name="share_with_title">Share with</string>
<string name="share_with_edit_title">Share with %1$s</string>
@@ -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)
}
}
+1
View File
@@ -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.