Show Android public link safety status
This commit is contained in:
+50
@@ -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,
|
||||
}
|
||||
+34
-4
@@ -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>
|
||||
|
||||
+66
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user