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 android.widget.ArrayAdapter
|
||||||
import eu.qsfera.android.databinding.SharePublicLinkItemBinding
|
import eu.qsfera.android.databinding.SharePublicLinkItemBinding
|
||||||
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
||||||
|
import eu.qsfera.android.R
|
||||||
import eu.qsfera.android.utils.PreferenceUtils
|
import eu.qsfera.android.utils.PreferenceUtils
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter to show a list of public links
|
* Adapter to show a list of public links
|
||||||
@@ -42,8 +45,6 @@ class SharePublicLinkListAdapter(
|
|||||||
private val listener: SharePublicLinkAdapterListener
|
private val listener: SharePublicLinkAdapterListener
|
||||||
) : ArrayAdapter<OCShare>(mContext, resource) {
|
) : ArrayAdapter<OCShare>(mContext, resource) {
|
||||||
|
|
||||||
private lateinit var binding: SharePublicLinkItemBinding
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
publicLinks = publicLinks.sortedBy { it.name }
|
publicLinks = publicLinks.sortedBy { it.name }
|
||||||
}
|
}
|
||||||
@@ -55,8 +56,8 @@ class SharePublicLinkListAdapter(
|
|||||||
override fun getItemId(position: Int): Long = 0
|
override fun getItemId(position: Int): Long = 0
|
||||||
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
val inflator = mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
val inflator = LayoutInflater.from(mContext)
|
||||||
binding = SharePublicLinkItemBinding.inflate(inflator).apply {
|
val binding = SharePublicLinkItemBinding.inflate(inflator, parent, false).apply {
|
||||||
root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext)
|
root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ class SharePublicLinkListAdapter(
|
|||||||
|
|
||||||
// If there's no name, set the token as name
|
// If there's no name, set the token as name
|
||||||
binding.publicLinkName.text = if (share.name.isNullOrEmpty()) share.token else share.name
|
binding.publicLinkName.text = if (share.name.isNullOrEmpty()) share.token else share.name
|
||||||
|
binding.publicLinkDetails.text = formatPublicLinkDetails(share)
|
||||||
|
|
||||||
// bind listener to get link
|
// bind listener to get link
|
||||||
binding.getPublicLinkButton.setOnClickListener { listener.copyOrSendPublicLink(publicLinks[position]) }
|
binding.getPublicLinkButton.setOnClickListener { listener.copyOrSendPublicLink(publicLinks[position]) }
|
||||||
@@ -79,6 +81,34 @@ class SharePublicLinkListAdapter(
|
|||||||
return binding.root
|
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 {
|
interface SharePublicLinkAdapterListener {
|
||||||
fun copyOrSendPublicLink(share: OCShare)
|
fun copyOrSendPublicLink(share: OCShare)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
-->
|
-->
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -25,8 +25,7 @@
|
|||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:weightSum="1">
|
android:weightSum="1">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/publicLinkName"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center_vertical"
|
android:layout_gravity="center_vertical"
|
||||||
@@ -34,11 +33,28 @@
|
|||||||
android:layout_marginStart="@dimen/standard_half_margin"
|
android:layout_marginStart="@dimen/standard_half_margin"
|
||||||
android:layout_marginTop="@dimen/standard_half_margin"
|
android:layout_marginTop="@dimen/standard_half_margin"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:ellipsize="middle"
|
android:orientation="vertical">
|
||||||
android:singleLine="true"
|
|
||||||
android:text="@string/username"
|
<TextView
|
||||||
android:textColor="@color/textColor"
|
android:id="@+id/publicLinkName"
|
||||||
android:textSize="@dimen/two_line_primary_text_size" />
|
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
|
<ImageButton
|
||||||
android:id="@+id/getPublicLinkButton"
|
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_only_label">Скачивание / Просмотр</string>
|
||||||
<string name="share_via_link_edit_permission_read_and_write_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_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_get_public_link_button">Получить ссылку</string>
|
||||||
<string name="share_with_title">Поделиться с</string>
|
<string name="share_with_title">Поделиться с</string>
|
||||||
<string name="share_with_edit_title">Поделиться с %1$s</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_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_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_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_get_public_link_button">Get link</string>
|
||||||
<string name="share_with_title">Share with</string>
|
<string name="share_with_title">Share with</string>
|
||||||
<string name="share_with_edit_title">Share with %1$s</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 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 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 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` 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/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.
|
- 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