Compare commits

..

17 Commits

Author SHA1 Message Date
Курнат Андрей 39d65fcede Align Keeper Argus publication flow 2026-06-20 19:53:04 +03:00
Курнат Андрей cdd442ebf7 Record Android 1.3.14 Argus publication 2026-06-13 20:07:01 +03:00
Курнат Андрей 0c5f178c8e Bump Android Argus release to 1.3.14 2026-06-13 20:02:56 +03:00
Курнат Андрей 9ad7f0abdb Align Android Argus publication with SSH contract 2026-06-13 19:47:25 +03:00
Курнат Андрей b9a0c26b0b Pin Android Argus release in production check 2026-06-12 23:05:26 +03:00
Курнат Андрей 9fad6b1595 Validate server compose env fragments 2026-06-12 23:02:01 +03:00
Курнат Андрей 709a38cae1 Add PowerShell server production env validation 2026-06-12 22:57:02 +03:00
Курнат Андрей 36f052223b Verify Android release signing continuity 2026-06-12 22:53:46 +03:00
Курнат Андрей 2607c7d08e Verify Windows desktop packaged runtime 2026-06-12 22:49:28 +03:00
Курнат Андрей da19575477 Record Android 1.3.13 Argus publication 2026-06-12 22:43:36 +03:00
Курнат Андрей 688c0a3b19 Bump Android release to 1.3.13 2026-06-12 22:39:51 +03:00
Курнат Андрей 251391b1a9 Require git provenance for Android Argus staging 2026-06-12 22:38:52 +03:00
Курнат Андрей 32f7c59bd4 Protect server-managed Android public links 2026-06-12 22:26:21 +03:00
Курнат Андрей c247af04a1 Show live Android backup queue status 2026-06-10 08:29:25 +03:00
Курнат Андрей dfe31ac68b Fix Windows tray show foregrounding 2026-06-10 08:26:50 +03:00
Курнат Андрей 439cf82893 Show detailed Android backup status 2026-06-10 08:08:00 +03:00
Курнат Андрей b3016e6c99 Audit sharing controls coverage 2026-06-10 07:56:02 +03:00
33 changed files with 2304 additions and 351 deletions
+3
View File
@@ -16,6 +16,9 @@ local.properties
argus-signing.local.properties
signing/
# Local temporary release scratch files
.codex-temp/
# Mac .DS_Store files
.DS_Store
+10 -5
View File
@@ -5,19 +5,24 @@
- Android update requirement: Android Developers says an update must use the same application ID and the same signing certificate as the installed app, unless it contains a valid proof-of-rotation: <https://developer.android.com/google/play/app-updates>.
- QSfera update implementation: `Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt`.
- QSfera Android version and release signing env names: `Android/qsferaApp/build.gradle`.
- Argus publication authority: `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`.
- Current Argus release and certificate facts verified for this production-readiness pass: `PRODUCTION_READINESS.md`, section `Android Argus Publication Check`.
## Policy
- Stable Argus releases for `qsfera-mobile` must keep the same package name and production signing certificate after the first production-signed `1.3.11` / `39` release.
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, monotonically newer `versionCode`, Argus `androidVersionCode`, and signer digest before starting Android package installation.
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.12` / `40` as of 2026-06-10) by the in-app updater because the signer digest check intentionally fails.
- Argus publication is SSH-only and must follow `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: copy the APK to the Pi, write metadata to `/srv/argus-data/argus.db`, and store package bytes under `/srv/argus-data/Packages`. Do not add or use HTTP `/api/admin/*` publication routes from this repository.
- The in-app updater must use the public read-only manifest endpoint, treat HTTP `404` as no update, compare `release.version` with the installed version, download `ARGUS_BASE_URL + release.downloadPath`, verify `release.packageSizeBytes` and `release.sha256`, and only then start Android package installation.
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, newer installed APK metadata, optional Argus `androidVersionCode` when present, manifest version, and signer digest before starting Android package installation.
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.14` / `42` as of 2026-06-13) by the in-app updater because the signer digest check intentionally fails.
- For debug-signed `1.3.5` installs, the supported migration path is: back up unsynced local files, uninstall the debug-signed build, then install the current stable QSfera APK from Argus.
- On certificate mismatch, the Android app must show an explicit reinstall-required dialog instead of a generic update error.
## Release Checklist
1. Build the stable APK with the same release key material through `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
2. Verify the APK certificate before upload with `apksigner verify --print-certs`.
3. Publish to Argus only after certificate, package name, version code, and SHA-256 checks pass.
4. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
2. Prepare Argus publication only from a clean release branch whose `HEAD` matches its upstream; `Android/scripts/prepare-argus-publication.ps1` enforces this by default and records repository root, branch, upstream, commit, and verification time in `argus-publication.json`. When the Android checkout is used from the pushed `QSfera.git` monorepo, run it from `Android/` with `-GitProvenanceRoot ..`.
3. Verify the local release signing material and latest Argus APK certificate with `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk`.
4. Verify the APK certificate before upload with `apksigner verify --print-certs`.
5. Publish to Argus only through `Android/scripts/publish-release.ps1`, which implements the SSH/SQLite/Data/Packages workflow from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; do not publish through HTTP admin APIs.
6. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
+2 -2
View File
@@ -135,8 +135,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 40
versionName = "1.3.12"
versionCode = 42
versionName = "1.3.14"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -0,0 +1,60 @@
/**
* qsfera Android client application
*
* Copyright (C) 2026 QSfera.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*/
package eu.qsfera.android.presentation.settings.automaticuploads
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
internal object AutomaticUploadRuntimeStatusResolver {
fun pauseReasons(
context: Context,
configuration: FolderBackUpConfiguration,
transferSummary: AutomaticUploadTransferSummary,
): Set<AutomaticUploadPauseReason> =
AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = configuration,
transferSummary = transferSummary,
isActiveNetworkMetered = isActiveNetworkMetered(context),
isActiveNetworkRoaming = isActiveNetworkRoaming(context),
isCharging = isCharging(context),
)
private fun isActiveNetworkMetered(context: Context): Boolean? {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return null
return connectivityManager.isActiveNetworkMetered
}
private fun isActiveNetworkRoaming(context: Context): Boolean? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null
}
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return null
val activeNetwork = connectivityManager.activeNetwork ?: return null
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return null
return !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
}
private fun isCharging(context: Context): Boolean {
val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
return status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
}
}
@@ -13,6 +13,12 @@ package eu.qsfera.android.presentation.settings.automaticuploads
import android.content.Context
import eu.qsfera.android.R
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.transfers.model.OCTransfer
import eu.qsfera.android.domain.transfers.model.TransferStatus
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.domain.user.model.UserQuota
import eu.qsfera.android.domain.user.model.UserQuotaState
import eu.qsfera.android.utils.DisplayUtils
internal object AutomaticUploadStatusFormatter {
@@ -21,19 +27,204 @@ internal object AutomaticUploadStatusFormatter {
configuration: FolderBackUpConfiguration?,
sourcePaths: List<String>,
lastSyncSummary: String,
unreadableSourcePaths: Set<String> = emptySet(),
userQuota: UserQuota? = null,
sourcePathLabels: Map<String, String> = emptyMap(),
transferSummary: AutomaticUploadTransferSummary = AutomaticUploadTransferSummary.EMPTY,
pauseReasons: Set<AutomaticUploadPauseReason> = emptySet(),
): String {
if (configuration == null) {
val status = statusFor(
configuration = configuration,
sourcePaths = sourcePaths,
unreadableSourcePaths = unreadableSourcePaths,
userQuota = userQuota,
)
if (status.runState == AutomaticUploadRunState.OFF) {
return context.getString(R.string.prefs_camera_upload_status_off)
}
val enabledConfiguration = configuration ?: return context.getString(R.string.prefs_camera_upload_status_off)
return listOf(
context.getString(R.string.prefs_camera_upload_status_on),
formatSourceCount(context, sourcePaths.size),
formatConditions(context, configuration),
formatRunState(context, status.runState),
formatTransferSummary(context, transferSummary),
formatPauseReasons(context, pauseReasons),
formatSourceCount(context, status.sourceStatuses.size),
formatSourceDetails(context, status.sourceStatuses, sourcePathLabels),
formatConditions(context, enabledConfiguration),
context.getString(R.string.prefs_camera_upload_status_last_scan, lastSyncSummary),
).joinToString(separator = "\n")
formatStorageWarning(context, status.storageWarning),
).filter { it.isNotEmpty() }.joinToString(separator = "\n")
}
fun statusFor(
configuration: FolderBackUpConfiguration?,
sourcePaths: List<String>,
unreadableSourcePaths: Set<String> = emptySet(),
userQuota: UserQuota? = null,
): AutomaticUploadStatus {
if (configuration == null) {
return AutomaticUploadStatus(
runState = AutomaticUploadRunState.OFF,
sourceStatuses = emptyList(),
storageWarning = AutomaticUploadStorageWarning.NONE,
)
}
val currentSourceSyncTimestamps = configuration.sourceSyncTimestamps
val sourceStatuses = sourcePaths.distinct().map { sourcePath ->
val lastSyncTimestamp = currentSourceSyncTimestamps[sourcePath] ?: configuration.lastSyncTimestamp
AutomaticUploadSourceStatus(
sourcePath = sourcePath,
lastSyncTimestamp = lastSyncTimestamp,
state = when {
sourcePath in unreadableSourcePaths -> AutomaticUploadSourceState.NEEDS_ACCESS
lastSyncTimestamp <= FolderBackUpConfiguration.initialSyncTimestamp ->
AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN
else -> AutomaticUploadSourceState.SCANNED
}
)
}
val runState = when {
sourceStatuses.isEmpty() -> AutomaticUploadRunState.NEEDS_SOURCE
sourceStatuses.any { it.state == AutomaticUploadSourceState.NEEDS_ACCESS } ->
AutomaticUploadRunState.NEEDS_SOURCE_ACCESS
sourceStatuses.any { it.state == AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN } ->
AutomaticUploadRunState.WAITING_FOR_FIRST_SCAN
else -> AutomaticUploadRunState.READY
}
return AutomaticUploadStatus(
runState = runState,
sourceStatuses = sourceStatuses,
storageWarning = resolveStorageWarning(userQuota),
)
}
fun resolvePauseReasons(
configuration: FolderBackUpConfiguration,
transferSummary: AutomaticUploadTransferSummary,
isActiveNetworkMetered: Boolean?,
isActiveNetworkRoaming: Boolean?,
isCharging: Boolean,
): Set<AutomaticUploadPauseReason> {
if (transferSummary.queuedCount == 0) {
return emptySet()
}
val reasons = linkedSetOf<AutomaticUploadPauseReason>()
if (configuration.wifiOnly && isActiveNetworkMetered != false) {
reasons += AutomaticUploadPauseReason.WAITING_FOR_UNMETERED_NETWORK
}
if (!configuration.wifiOnly && !configuration.allowRoaming && isActiveNetworkRoaming == true) {
reasons += AutomaticUploadPauseReason.WAITING_FOR_NOT_ROAMING_NETWORK
}
if (configuration.chargingOnly && !isCharging) {
reasons += AutomaticUploadPauseReason.WAITING_FOR_CHARGING
}
return reasons
}
private fun formatRunState(context: Context, runState: AutomaticUploadRunState): String =
when (runState) {
AutomaticUploadRunState.OFF -> context.getString(R.string.prefs_camera_upload_status_off)
AutomaticUploadRunState.NEEDS_SOURCE -> context.getString(R.string.prefs_camera_upload_status_needs_source)
AutomaticUploadRunState.NEEDS_SOURCE_ACCESS ->
context.getString(R.string.prefs_camera_upload_status_needs_source_access)
AutomaticUploadRunState.WAITING_FOR_FIRST_SCAN ->
context.getString(R.string.prefs_camera_upload_status_waiting_first_scan)
AutomaticUploadRunState.READY -> context.getString(R.string.prefs_camera_upload_status_ready)
}
private fun formatTransferSummary(
context: Context,
transferSummary: AutomaticUploadTransferSummary,
): String =
if (transferSummary == AutomaticUploadTransferSummary.EMPTY) {
""
} else {
context.getString(
R.string.prefs_camera_upload_status_transfer_summary,
transferSummary.inProgressCount,
transferSummary.queuedCount,
transferSummary.failedCount
)
}
private fun formatPauseReasons(context: Context, pauseReasons: Set<AutomaticUploadPauseReason>): String =
pauseReasons.joinToString(separator = "\n") { pauseReason ->
when (pauseReason) {
AutomaticUploadPauseReason.WAITING_FOR_UNMETERED_NETWORK ->
context.getString(R.string.prefs_camera_upload_status_paused_unmetered)
AutomaticUploadPauseReason.WAITING_FOR_NOT_ROAMING_NETWORK ->
context.getString(R.string.prefs_camera_upload_status_paused_not_roaming)
AutomaticUploadPauseReason.WAITING_FOR_CHARGING ->
context.getString(R.string.prefs_camera_upload_status_paused_charging)
}
}
private fun formatSourceDetails(
context: Context,
sourceStatuses: List<AutomaticUploadSourceStatus>,
sourcePathLabels: Map<String, String>,
): String =
sourceStatuses
.take(MAX_SOURCE_STATUS_LINES)
.map { sourceStatus ->
val label = sourcePathLabels[sourceStatus.sourcePath] ?: sourceStatus.sourcePath
when (sourceStatus.state) {
AutomaticUploadSourceState.NEEDS_ACCESS ->
context.getString(R.string.prefs_camera_upload_status_source_needs_access, label)
AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN ->
context.getString(R.string.prefs_camera_upload_status_source_waiting, label)
AutomaticUploadSourceState.SCANNED ->
context.getString(
R.string.prefs_camera_upload_status_source_scanned,
label,
DisplayUtils.unixTimeToHumanReadable(sourceStatus.lastSyncTimestamp)
)
}
}
.plus(formatHiddenSourceCount(context, sourceStatuses.size))
.filter { it.isNotEmpty() }
.joinToString(separator = "\n")
private fun formatHiddenSourceCount(context: Context, sourceCount: Int): String =
if (sourceCount > MAX_SOURCE_STATUS_LINES) {
context.getString(
R.string.prefs_camera_upload_status_more_sources,
sourceCount - MAX_SOURCE_STATUS_LINES
)
} else {
""
}
private fun formatStorageWarning(
context: Context,
storageWarning: AutomaticUploadStorageWarning,
): String =
when (storageWarning) {
AutomaticUploadStorageWarning.NONE -> ""
AutomaticUploadStorageWarning.NEARING ->
context.getString(R.string.prefs_camera_upload_status_storage_nearing)
AutomaticUploadStorageWarning.CRITICAL ->
context.getString(R.string.prefs_camera_upload_status_storage_critical)
AutomaticUploadStorageWarning.EXCEEDED ->
context.getString(R.string.prefs_camera_upload_status_storage_exceeded)
}
private fun resolveStorageWarning(userQuota: UserQuota?): AutomaticUploadStorageWarning =
when {
userQuota == null -> AutomaticUploadStorageWarning.NONE
userQuota.available < 0L -> AutomaticUploadStorageWarning.NONE
userQuota.available == 0L -> AutomaticUploadStorageWarning.EXCEEDED
userQuota.state == UserQuotaState.EXCEEDED -> AutomaticUploadStorageWarning.EXCEEDED
userQuota.state == UserQuotaState.CRITICAL -> AutomaticUploadStorageWarning.CRITICAL
userQuota.state == UserQuotaState.NEARING -> AutomaticUploadStorageWarning.NEARING
else -> AutomaticUploadStorageWarning.NONE
}
private fun formatSourceCount(context: Context, sourceCount: Int): String =
if (sourceCount == 0) {
context.getString(R.string.prefs_camera_upload_source_paths_empty)
@@ -61,7 +252,7 @@ internal object AutomaticUploadStatusFormatter {
if (configuration.chargingOnly) {
conditions += context.getString(R.string.prefs_camera_upload_status_charging_only)
}
return conditions.joinToString(separator = " ")
return conditions.joinToString(separator = " - ")
}
private fun formatMobileDataLimit(context: Context, mobileDataLimitBytes: Long): String {
@@ -71,4 +262,77 @@ internal object AutomaticUploadStatusFormatter {
return entries.getOrNull(index)
?: context.getString(R.string.prefs_camera_upload_status_custom_mobile_data_limit, mobileDataLimitBytes)
}
private const val MAX_SOURCE_STATUS_LINES = 3
}
internal data class AutomaticUploadStatus(
val runState: AutomaticUploadRunState,
val sourceStatuses: List<AutomaticUploadSourceStatus>,
val storageWarning: AutomaticUploadStorageWarning,
)
internal data class AutomaticUploadSourceStatus(
val sourcePath: String,
val lastSyncTimestamp: Long,
val state: AutomaticUploadSourceState,
)
internal enum class AutomaticUploadRunState {
OFF,
NEEDS_SOURCE,
NEEDS_SOURCE_ACCESS,
WAITING_FOR_FIRST_SCAN,
READY,
}
internal enum class AutomaticUploadSourceState {
NEEDS_ACCESS,
WAITING_FOR_FIRST_SCAN,
SCANNED,
}
internal enum class AutomaticUploadStorageWarning {
NONE,
NEARING,
CRITICAL,
EXCEEDED,
}
internal data class AutomaticUploadTransferSummary(
val inProgressCount: Int,
val queuedCount: Int,
val failedCount: Int,
) {
companion object {
val EMPTY = AutomaticUploadTransferSummary(
inProgressCount = 0,
queuedCount = 0,
failedCount = 0,
)
fun fromTransfers(
transfers: List<OCTransfer>,
createdBy: UploadEnqueuedBy,
): AutomaticUploadTransferSummary {
val automaticTransfers = transfers.filter { transfer -> transfer.createdBy == createdBy }
return AutomaticUploadTransferSummary(
inProgressCount = automaticTransfers.count { transfer ->
transfer.status == TransferStatus.TRANSFER_IN_PROGRESS
},
queuedCount = automaticTransfers.count { transfer ->
transfer.status == TransferStatus.TRANSFER_QUEUED
},
failedCount = automaticTransfers.count { transfer ->
transfer.status == TransferStatus.TRANSFER_FAILED
},
)
}
}
}
internal enum class AutomaticUploadPauseReason {
WAITING_FOR_UNMETERED_NETWORK,
WAITING_FOR_NOT_ROAMING_NETWORK,
WAITING_FOR_CHARGING,
}
@@ -31,6 +31,7 @@ import android.provider.DocumentsContract
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -51,12 +52,12 @@ import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_PATH
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
import eu.qsfera.android.extensions.showAlertDialog
import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
import eu.qsfera.android.ui.activity.FolderPickerActivity
import eu.qsfera.android.utils.DisplayUtils
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File
@@ -138,7 +139,13 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private fun initStateObservers() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collectLatestLifecycleFlow(manageAccountsViewModel.userQuotas) { listUserQuotas ->
combine(
manageAccountsViewModel.userQuotas,
picturesViewModel.pictureUploads,
picturesViewModel.pictureUploadsTransferSummary,
) { listUserQuotas, pictureUploadsConfiguration, transferSummary ->
Triple(listUserQuotas, pictureUploadsConfiguration, transferSummary)
}.collect { (listUserQuotas, pictureUploadsConfiguration, transferSummary) ->
val availableAccounts = listUserQuotas.filter { it.available != -4L }
prefPictureUploadsAccount?.apply {
entries = availableAccounts.map { it.accountName }.toTypedArray()
@@ -158,31 +165,41 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
}
picturesViewModel.pictureUploads.collect { pictureUploadsConfiguration ->
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
val sourcePaths = it.sourcePaths
val sourcePathLabels = getSourcePathLabels(sourcePaths)
prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
context = requireContext(),
configuration = it,
sourcePaths = sourcePaths,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths),
userQuota = availableAccounts.firstOrNull { userQuota ->
userQuota.accountName == it.accountName
},
sourcePathLabels = sourcePathLabels,
transferSummary = transferSummary,
pauseReasons = AutomaticUploadRuntimeStatusResolver.pauseReasons(
context = requireContext(),
configuration = it,
sourcePaths = sourcePaths,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
)
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
transferSummary = transferSummary,
),
)
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
}
}
@@ -340,13 +357,32 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
if (sourcePaths.isEmpty()) {
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
if (sourcePathLabels.isEmpty()) {
getString(R.string.prefs_camera_upload_source_paths_empty)
} else {
sourcePaths.joinToString(separator = "\n") { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
sourcePathLabels.values.joinToString(separator = "\n")
}
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
sourcePaths.associateWith { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
private fun getUnreadableSourcePaths(sourcePaths: List<String>): Set<String> =
sourcePaths.filterTo(linkedSetOf()) { sourcePath ->
!canReadSourcePath(sourcePath)
}
private fun canReadSourcePath(sourcePath: String): Boolean =
try {
val sourceUri = sourcePath.toUri()
val hasPersistedReadPermission = requireContext().contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(requireContext(), sourceUri)
hasPersistedReadPermission && documentTree?.canRead() == true
} catch (_: RuntimeException) {
false
}
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
@@ -41,13 +41,18 @@ import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FolderPickerActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -59,6 +64,7 @@ class SettingsPictureUploadsViewModel(
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
private val workManagerProvider: WorkManagerProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider,
@@ -69,6 +75,20 @@ class SettingsPictureUploadsViewModel(
private var pictureUploadsSpace: OCSpace? = null
internal val pictureUploadsTransferSummary: StateFlow<AutomaticUploadTransferSummary> =
getAllTransfersAsStreamUseCase(Unit)
.map { transfers ->
AutomaticUploadTransferSummary.fromTransfers(
transfers = transfers,
createdBy = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AutomaticUploadTransferSummary.EMPTY,
)
init {
initPictureUploads()
}
@@ -31,6 +31,7 @@ import android.provider.DocumentsContract
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
@@ -51,12 +52,12 @@ import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_PATH
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
import eu.qsfera.android.extensions.showAlertDialog
import eu.qsfera.android.extensions.showMessageInSnackbar
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
import eu.qsfera.android.ui.activity.FolderPickerActivity
import eu.qsfera.android.utils.DisplayUtils
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.io.File
@@ -136,7 +137,13 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private fun initLiveDataObservers() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collectLatestLifecycleFlow(manageAccountsViewModel.userQuotas) { listUserQuotas ->
combine(
manageAccountsViewModel.userQuotas,
videosViewModel.videoUploads,
videosViewModel.videoUploadsTransferSummary,
) { listUserQuotas, videoUploadsConfiguration, transferSummary ->
Triple(listUserQuotas, videoUploadsConfiguration, transferSummary)
}.collect { (listUserQuotas, videoUploadsConfiguration, transferSummary) ->
val availableAccounts = listUserQuotas.filter { it.available != -4L }
prefVideoUploadsAccount?.apply {
entries = availableAccounts.map { it.accountName }.toTypedArray()
@@ -156,31 +163,41 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
}
videosViewModel.videoUploads.collect { videoUploadsConfiguration ->
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
val sourcePaths = it.sourcePaths
val sourcePathLabels = getSourcePathLabels(sourcePaths)
prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
context = requireContext(),
configuration = it,
sourcePaths = sourcePaths,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths),
userQuota = availableAccounts.firstOrNull { userQuota ->
userQuota.accountName == it.accountName
},
sourcePathLabels = sourcePathLabels,
transferSummary = transferSummary,
pauseReasons = AutomaticUploadRuntimeStatusResolver.pauseReasons(
context = requireContext(),
configuration = it,
sourcePaths = sourcePaths,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
)
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
transferSummary = transferSummary,
),
)
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
}
}
@@ -338,13 +355,32 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
if (sourcePaths.isEmpty()) {
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
if (sourcePathLabels.isEmpty()) {
getString(R.string.prefs_camera_upload_source_paths_empty)
} else {
sourcePaths.joinToString(separator = "\n") { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
sourcePathLabels.values.joinToString(separator = "\n")
}
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
sourcePaths.associateWith { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
private fun getUnreadableSourcePaths(sourcePaths: List<String>): Set<String> =
sourcePaths.filterTo(linkedSetOf()) { sourcePath ->
!canReadSourcePath(sourcePath)
}
private fun canReadSourcePath(sourcePath: String): Boolean =
try {
val sourceUri = sourcePath.toUri()
val hasPersistedReadPermission = requireContext().contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(requireContext(), sourceUri)
hasPersistedReadPermission && documentTree?.canRead() == true
} catch (_: RuntimeException) {
false
}
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
@@ -41,13 +41,18 @@ import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FolderPickerActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -59,6 +64,7 @@ class SettingsVideoUploadsViewModel(
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
private val workManagerProvider: WorkManagerProvider,
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
private val contextProvider: ContextProvider,
@@ -69,6 +75,20 @@ class SettingsVideoUploadsViewModel(
private var videoUploadsSpace: OCSpace? = null
internal val videoUploadsTransferSummary: StateFlow<AutomaticUploadTransferSummary> =
getAllTransfersAsStreamUseCase(Unit)
.map { transfers ->
AutomaticUploadTransferSummary.fromTransfers(
transfers = transfers,
createdBy = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AutomaticUploadTransferSummary.EMPTY,
)
init {
initVideoUploads()
}
@@ -44,7 +44,7 @@ class AppUpdateManager(private val fragment: Fragment) {
runCatching {
withContext(Dispatchers.IO) { fetchLatestRelease(context) }
}.onSuccess { release ->
if (release.androidVersionCode <= BuildConfig.VERSION_CODE) {
if (release == null || compareVersions(release.version, BuildConfig.VERSION_NAME) <= 0) {
showNoUpdatesDialog(context)
} else {
showUpdateDialog(context, release)
@@ -62,9 +62,7 @@ class AppUpdateManager(private val fragment: Fragment) {
context.getString(
R.string.update_available_message,
release.version,
release.androidVersionCode,
BuildConfig.VERSION_NAME,
BuildConfig.VERSION_CODE
BuildConfig.VERSION_NAME
)
)
.setNegativeButton(android.R.string.cancel, null)
@@ -95,8 +93,13 @@ class AppUpdateManager(private val fragment: Fragment) {
fragment.lifecycleScope.launch {
runCatching {
withContext(Dispatchers.IO) {
downloadApk(context, release).also { apk ->
val apk = downloadApk(context, release)
try {
validateDownloadedApk(context, apk, release)
apk
} catch (error: Throwable) {
apk.delete()
throw error
}
}
}.onSuccess { apk ->
@@ -139,25 +142,45 @@ class AppUpdateManager(private val fragment: Fragment) {
.show()
}
private fun fetchLatestRelease(context: Context): ArgusRelease {
val json = requestText(ARGUS_MANIFEST_URL)
private fun fetchLatestRelease(context: Context): ArgusRelease? {
val json = requestText(ARGUS_MANIFEST_URL) ?: return null
val release = JSONObject(json).optJSONObject("release")
?: error(context.getString(R.string.update_release_not_found))
val version = release.getString("version")
val channel = release.optString("channel")
val platform = release.optString("platform")
val packageKind = release.optString("packageKind")
val downloadPath = release.getString("downloadPath")
val sha256 = release.optString("sha256")
val packageSizeBytes = release.optLong("packageSizeBytes", -1L)
if (channel != ARGUS_CHANNEL ||
platform != ARGUS_PLATFORM ||
packageKind != ARGUS_PACKAGE_KIND ||
sha256.isBlank() ||
packageSizeBytes <= 0L
) {
error(context.getString(R.string.update_metadata_error))
}
return ArgusRelease(
version = release.getString("version"),
androidVersionCode = release.getInt("androidVersionCode"),
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
sha256 = release.optString("sha256")
version = version,
androidVersionCode = release.optInt("androidVersionCode").takeIf { it > 0 },
downloadUrl = downloadPath.toAbsoluteArgusUrl(),
packageSizeBytes = packageSizeBytes,
sha256 = sha256.lowercase()
)
}
private fun requestText(url: String): String {
private fun requestText(url: String): String? {
val connection = URL(url).openConnection() as HttpURLConnection
return try {
connection.connectTimeout = NETWORK_TIMEOUT_MS
connection.readTimeout = NETWORK_TIMEOUT_MS
connection.requestMethod = "GET"
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
return null
}
check(connection.responseCode in 200..299) {
"HTTP ${connection.responseCode}"
}
@@ -170,6 +193,9 @@ class AppUpdateManager(private val fragment: Fragment) {
private fun downloadApk(context: Context, release: ArgusRelease): File {
val updatesDir = File(context.filesDir, "updates").apply { mkdirs() }
val apkFile = File(updatesDir, UPDATE_APK_NAME)
if (apkFile.exists()) {
apkFile.delete()
}
val connection = URL(release.downloadUrl).openConnection() as HttpURLConnection
try {
@@ -188,7 +214,13 @@ class AppUpdateManager(private val fragment: Fragment) {
connection.disconnect()
}
if (release.sha256.isNotBlank() && apkFile.sha256() != release.sha256.lowercase()) {
if (apkFile.length() != release.packageSizeBytes) {
apkFile.delete()
error(context.getString(R.string.update_metadata_error))
}
if (apkFile.sha256() != release.sha256) {
apkFile.delete()
error(context.getString(R.string.update_digest_error))
}
@@ -220,7 +252,10 @@ class AppUpdateManager(private val fragment: Fragment) {
if (downloadedVersionCode <= BuildConfig.VERSION_CODE) {
error(context.getString(R.string.update_version_error))
}
if (downloadedVersionCode != release.androidVersionCode.toLong()) {
if (release.androidVersionCode != null && downloadedVersionCode != release.androidVersionCode.toLong()) {
error(context.getString(R.string.update_metadata_error))
}
if (downloadedPackageInfo.versionName != release.version) {
error(context.getString(R.string.update_metadata_error))
}
@@ -326,10 +361,29 @@ class AppUpdateManager(private val fragment: Fragment) {
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun compareVersions(candidate: String, installed: String): Int {
val candidateParts = candidate.toVersionParts()
val installedParts = installed.toVersionParts()
val maxParts = maxOf(candidateParts.size, installedParts.size)
for (index in 0 until maxParts) {
val candidatePart = candidateParts.getOrElse(index) { 0 }
val installedPart = installedParts.getOrElse(index) { 0 }
if (candidatePart != installedPart) {
return candidatePart.compareTo(installedPart)
}
}
return candidate.compareTo(installed, ignoreCase = true)
}
private fun String.toVersionParts(): List<Int> =
split('.', '-', '_')
.mapNotNull { part -> part.takeWhile { it.isDigit() }.toIntOrNull() }
private data class ArgusRelease(
val version: String,
val androidVersionCode: Int,
val androidVersionCode: Int?,
val downloadUrl: String,
val packageSizeBytes: Long,
val sha256: String
)
@@ -337,8 +391,11 @@ class AppUpdateManager(private val fragment: Fragment) {
companion object {
private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz"
private const val ARGUS_PLATFORM = "android"
private const val ARGUS_CHANNEL = "stable"
private const val ARGUS_PACKAGE_KIND = "apk"
private const val ARGUS_MANIFEST_URL =
"$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=android&channel=stable"
"$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
private const val UPDATE_APK_NAME = "qsfera-update.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val NETWORK_TIMEOUT_MS = 15_000
@@ -238,16 +238,16 @@ class PublicShareDialogFragment : DialogFragment() {
if (updating()) {
binding.publicShareDialogTitle.setText(R.string.share_via_link_edit_title)
binding.shareViaLinkNameValue.setText(publicShare?.name)
val publicShareStatus = publicShare?.let { PublicShareStatusFormatter.statusFor(it) }
when (publicShare?.permissions) {
RemoteShare.CREATE_PERMISSION_FLAG
or RemoteShare.DELETE_PERMISSION_FLAG
or RemoteShare.UPDATE_PERMISSION_FLAG
or RemoteShare.READ_PERMISSION_FLAG ->
when (publicShareStatus?.accessMode) {
PublicShareAccessMode.READ_AND_WRITE ->
binding.shareViaLinkEditPermissionReadAndWrite.isChecked = true
RemoteShare.CREATE_PERMISSION_FLAG -> binding.shareViaLinkEditPermissionUploadFiles.isChecked = true
else -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true
PublicShareAccessMode.UPLOAD_ONLY -> binding.shareViaLinkEditPermissionUploadFiles.isChecked = true
PublicShareAccessMode.READ_ONLY -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true
PublicShareAccessMode.CUSTOM -> binding.shareViaLinkEditPermissionGroup.clearCheck()
null -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true
}
if (publicShare?.isPasswordProtected == true) {
@@ -265,12 +265,19 @@ class PublicShareDialogFragment : DialogFragment() {
binding.shareViaLinkExpirationValue.text = formattedDate
}
if (publicShareStatus?.isEditableInOcsDialog == false) {
applyServerManagedPublicLinkState()
}
} else {
binding.shareViaLinkNameValue.setText(arguments?.getString(ARG_DEFAULT_LINK_NAME, ""))
}
}
private fun onSaveShareSetting() {
if (isServerManagedPublicLink()) {
return
}
// Get data filled by user
val publicLinkName = binding.shareViaLinkNameValue.text.toString()
var publicLinkPassword: String? = binding.shareViaLinkPasswordValue.text.toString()
@@ -826,6 +833,11 @@ class PublicShareDialogFragment : DialogFragment() {
QSferaVersion(it, it) // FIXME: check productversion as second parameter, not versionstring
}
if (isServerManagedPublicLink()) {
applyServerManagedPublicLinkState()
return
}
// Show keyboard to fill the public share name
dialog?.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
@@ -932,6 +944,23 @@ class PublicShareDialogFragment : DialogFragment() {
}
}
private fun isServerManagedPublicLink(): Boolean =
publicShare?.let { share -> !PublicShareStatusFormatter.statusFor(share).isEditableInOcsDialog } == true
private fun applyServerManagedPublicLinkState() {
binding.shareViaLinkManagedAccessMessage.isVisible = true
binding.shareViaLinkEditPermissionGroup.isVisible = false
binding.shareViaLinkNameValue.isEnabled = false
binding.shareViaLinkPasswordSwitch.isEnabled = false
binding.shareViaLinkPasswordValue.isEnabled = false
binding.generatePasswordButton.isEnabled = false
binding.copyPasswordButton.isEnabled = false
binding.shareViaLinkExpirationSwitch.isEnabled = false
binding.shareViaLinkExpirationLabel.isEnabled = false
binding.shareViaLinkExpirationValue.isEnabled = false
binding.saveButton.isEnabled = false
}
private fun isReadOnlyPermission() = binding.shareViaLinkEditPermissionReadOnly.isChecked &&
capabilities?.filesSharingPublicPasswordEnforcedReadOnly == CapabilityBooleanType.TRUE
@@ -6,11 +6,14 @@ 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 }
)
accessModeFor(share.permissions).let { accessMode ->
PublicShareStatus(
accessMode = accessMode,
isPasswordProtected = share.isPasswordProtected,
expirationDateInMillis = share.expirationDate.takeIf { it > 0 },
isEditableInOcsDialog = accessMode != PublicShareAccessMode.CUSTOM,
)
}
private fun accessModeFor(permissions: Int): PublicShareAccessMode {
val canRead = permissions hasPermission RemoteShare.READ_PERMISSION_FLAG
@@ -39,7 +42,8 @@ internal object PublicShareStatusFormatter {
internal data class PublicShareStatus(
val accessMode: PublicShareAccessMode,
val isPasswordProtected: Boolean,
val expirationDateInMillis: Long?
val expirationDateInMillis: Long?,
val isEditableInOcsDialog: Boolean,
)
internal enum class PublicShareAccessMode {
@@ -76,6 +76,21 @@
</RadioGroup>
<TextView
android:id="@+id/shareViaLinkManagedAccessMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/standard_half_margin"
android:layout_marginRight="@dimen/standard_half_margin"
android:drawableStart="@drawable/common_error_grey"
android:drawablePadding="5dp"
android:gravity="center_vertical"
android:padding="@dimen/standard_half_padding"
android:text="@string/share_via_link_managed_access_message"
android:textColor="@color/secondaryTextColor"
android:textSize="13sp"
android:visibility="gone" />
<RelativeLayout
android:id="@+id/shareViaLinkPasswordSection"
android:layout_width="match_parent"
@@ -110,7 +110,7 @@
<string name="update_checking">Проверка обновлений…</string>
<string name="update_downloading">Скачивание обновления…</string>
<string name="update_available_title">Доступно обновление</string>
<string name="update_available_message">Доступна версия %1$s (%2$d). Текущая версия: %3$s (%4$d).</string>
<string name="update_available_message">Доступна версия %1$s. Текущая версия: %2$s.</string>
<string name="update_not_available_title">Обновлений нет</string>
<string name="update_not_available_message">Установлена последняя доступная версия.</string>
<string name="update_install">Установить</string>
@@ -566,18 +566,33 @@
<string name="prefs_camera_upload_status_title">Состояние копирования</string>
<string name="prefs_camera_upload_status_off">Выключено</string>
<string name="prefs_camera_upload_status_on">Включено</string>
<string name="prefs_camera_upload_status_ready">Готово к следующей проверке</string>
<string name="prefs_camera_upload_status_needs_source">Добавьте папку телефона, чтобы начать копирование</string>
<string name="prefs_camera_upload_status_needs_source_access">Одной или нескольким папкам телефона снова нужен доступ</string>
<string name="prefs_camera_upload_status_waiting_first_scan">Ожидает первой проверки копирования</string>
<string name="prefs_camera_upload_status_transfer_summary">Загрузки: выполняется %1$d, в очереди %2$d, ошибок %3$d</string>
<string name="prefs_camera_upload_status_paused_unmetered">Приостановлено: ожидается Wi-Fi или другая сеть без тарификации</string>
<string name="prefs_camera_upload_status_paused_not_roaming">Приостановлено: ожидается сеть без роуминга</string>
<string name="prefs_camera_upload_status_paused_charging">Приостановлено: ожидается зарядка</string>
<plurals name="prefs_camera_upload_status_source_count">
<item quantity="one">%d папка телефона</item>
<item quantity="few">%d папки телефона</item>
<item quantity="many">%d папок телефона</item>
<item quantity="other">%d папки телефона</item>
</plurals>
<string name="prefs_camera_upload_status_source_scanned">%1$s: последняя проверка %2$s</string>
<string name="prefs_camera_upload_status_source_waiting">%1$s: ожидает первой проверки</string>
<string name="prefs_camera_upload_status_source_needs_access">%1$s: нет доступа</string>
<string name="prefs_camera_upload_status_more_sources">Ещё папок не показано: %1$d</string>
<string name="prefs_camera_upload_status_wifi_only">Только Wi-Fi</string>
<string name="prefs_camera_upload_status_mobile_data">Мобильные данные: %1$s</string>
<string name="prefs_camera_upload_status_custom_mobile_data_limit">%1$d байт в день</string>
<string name="prefs_camera_upload_status_roaming_allowed">роуминг разрешён</string>
<string name="prefs_camera_upload_status_charging_only">только во время зарядки</string>
<string name="prefs_camera_upload_status_last_scan">Последняя проверка: %1$s</string>
<string name="prefs_camera_upload_status_storage_nearing">Место в учётной записи почти закончилось; копирование может скоро остановиться</string>
<string name="prefs_camera_upload_status_storage_critical">Место в учётной записи почти полностью занято; копирование может завершиться ошибкой</string>
<string name="prefs_camera_upload_status_storage_exceeded">Место в учётной записи закончилось; новые копии не загрузятся, пока не освободится место</string>
<string name="prefs_camera_upload_allow_roaming">Разрешить копирование в роуминге</string>
<string name="prefs_camera_upload_allow_roaming_summary">Применяется только если копирование только по Wi-Fi выключено</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл после копирования</string>
@@ -613,6 +628,7 @@
<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_via_link_managed_access_message">Эта ссылка использует доступ, управляемый сервером: например, доступ только для организации или безопасный просмотр без скачивания. Здесь ее можно скопировать или удалить. Изменяйте ее в веб-приложении, чтобы не изменить защиту.</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>
@@ -112,7 +112,7 @@
<string name="update_checking">Checking for updates…</string>
<string name="update_downloading">Downloading update…</string>
<string name="update_available_title">Update available</string>
<string name="update_available_message">Version %1$s (%2$d) is available. Current version: %3$s (%4$d).</string>
<string name="update_available_message">Version %1$s is available. Current version: %2$s.</string>
<string name="update_not_available_title">No updates</string>
<string name="update_not_available_message">You are using the latest available version.</string>
<string name="update_install">Install</string>
@@ -605,16 +605,31 @@
<string name="prefs_camera_upload_status_title">Backup status</string>
<string name="prefs_camera_upload_status_off">Off</string>
<string name="prefs_camera_upload_status_on">On</string>
<string name="prefs_camera_upload_status_ready">Ready for the next scan</string>
<string name="prefs_camera_upload_status_needs_source">Add a phone folder to start backup</string>
<string name="prefs_camera_upload_status_needs_source_access">One or more phone folders need access again</string>
<string name="prefs_camera_upload_status_waiting_first_scan">Waiting for the first backup scan</string>
<string name="prefs_camera_upload_status_transfer_summary">Uploads: %1$d uploading, %2$d queued, %3$d failed</string>
<string name="prefs_camera_upload_status_paused_unmetered">Paused: waiting for Wi-Fi or another unmetered network</string>
<string name="prefs_camera_upload_status_paused_not_roaming">Paused: waiting for a non-roaming network</string>
<string name="prefs_camera_upload_status_paused_charging">Paused: waiting for charging</string>
<plurals name="prefs_camera_upload_status_source_count">
<item quantity="one">%d phone folder</item>
<item quantity="other">%d phone folders</item>
</plurals>
<string name="prefs_camera_upload_status_source_scanned">%1$s: last scanned %2$s</string>
<string name="prefs_camera_upload_status_source_waiting">%1$s: waiting for first scan</string>
<string name="prefs_camera_upload_status_source_needs_access">%1$s: access missing</string>
<string name="prefs_camera_upload_status_more_sources">%1$d more folders not shown</string>
<string name="prefs_camera_upload_status_wifi_only">Wi-Fi only</string>
<string name="prefs_camera_upload_status_mobile_data">Mobile data: %1$s</string>
<string name="prefs_camera_upload_status_custom_mobile_data_limit">%1$d bytes per day</string>
<string name="prefs_camera_upload_status_roaming_allowed">roaming allowed</string>
<string name="prefs_camera_upload_status_charging_only">charging only</string>
<string name="prefs_camera_upload_status_last_scan">Last scan: %1$s</string>
<string name="prefs_camera_upload_status_storage_nearing">Account storage is nearly full; backup may stop soon</string>
<string name="prefs_camera_upload_status_storage_critical">Account storage is very nearly full; backup may fail</string>
<string name="prefs_camera_upload_status_storage_exceeded">Account storage is full; new backups will fail until space is freed</string>
<string name="prefs_camera_upload_allow_roaming">Allow backup while roaming</string>
<string name="prefs_camera_upload_allow_roaming_summary">Only applies when Wi-Fi-only backup is off</string>
<string name="prefs_camera_upload_mobile_data_limit_title">Mobile data daily limit</string>
@@ -668,6 +683,7 @@
<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_via_link_managed_access_message">This link uses server-managed access, such as organization-only access or secure view without downloads. You can copy or remove it here. Edit it from the web app to avoid changing its protection.</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>
@@ -0,0 +1,253 @@
package eu.qsfera.android.presentation.settings.automaticuploads
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.domain.transfers.model.OCTransfer
import eu.qsfera.android.domain.transfers.model.TransferStatus
import eu.qsfera.android.domain.transfers.model.UploadEnqueuedBy
import eu.qsfera.android.domain.user.model.UserQuota
import eu.qsfera.android.domain.user.model.UserQuotaState
import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.File
class AutomaticUploadStatusFormatterTest {
@Test
fun `statusFor reports disabled backup when configuration is missing`() {
val status = AutomaticUploadStatusFormatter.statusFor(
configuration = null,
sourcePaths = emptyList(),
)
assertEquals(AutomaticUploadRunState.OFF, status.runState)
assertEquals(emptyList<AutomaticUploadSourceStatus>(), status.sourceStatuses)
assertEquals(AutomaticUploadStorageWarning.NONE, status.storageWarning)
}
@Test
fun `statusFor asks for source folder when backup is enabled without sources`() {
val status = AutomaticUploadStatusFormatter.statusFor(
configuration = backupConfiguration(sourcePaths = emptyList()),
sourcePaths = emptyList(),
)
assertEquals(AutomaticUploadRunState.NEEDS_SOURCE, status.runState)
assertEquals(emptyList<AutomaticUploadSourceStatus>(), status.sourceStatuses)
}
@Test
fun `statusFor reports per-source scanned and waiting states`() {
val camera = "content://camera"
val screenshots = "content://screenshots"
val status = AutomaticUploadStatusFormatter.statusFor(
configuration = backupConfiguration(
sourcePaths = listOf(camera, screenshots),
sourceSyncTimestamps = mapOf(camera to 1_000L),
),
sourcePaths = listOf(camera, screenshots),
)
assertEquals(AutomaticUploadRunState.WAITING_FOR_FIRST_SCAN, status.runState)
assertEquals(
listOf(
AutomaticUploadSourceStatus(camera, 1_000L, AutomaticUploadSourceState.SCANNED),
AutomaticUploadSourceStatus(screenshots, 0L, AutomaticUploadSourceState.WAITING_FOR_FIRST_SCAN),
),
status.sourceStatuses
)
}
@Test
fun `statusFor reports missing source access before timestamp state`() {
val camera = "content://camera"
val status = AutomaticUploadStatusFormatter.statusFor(
configuration = backupConfiguration(
sourcePaths = listOf(camera),
sourceSyncTimestamps = mapOf(camera to 1_000L),
),
sourcePaths = listOf(camera),
unreadableSourcePaths = setOf(camera),
)
assertEquals(AutomaticUploadRunState.NEEDS_SOURCE_ACCESS, status.runState)
assertEquals(
listOf(AutomaticUploadSourceStatus(camera, 1_000L, AutomaticUploadSourceState.NEEDS_ACCESS)),
status.sourceStatuses
)
}
@Test
fun `statusFor reports storage warnings from selected account quota`() {
assertEquals(
AutomaticUploadStorageWarning.NEARING,
AutomaticUploadStatusFormatter.statusFor(
configuration = backupConfiguration(),
sourcePaths = listOf(DEFAULT_SOURCE_PATH),
userQuota = quota(state = UserQuotaState.NEARING),
).storageWarning
)
assertEquals(
AutomaticUploadStorageWarning.CRITICAL,
AutomaticUploadStatusFormatter.statusFor(
configuration = backupConfiguration(),
sourcePaths = listOf(DEFAULT_SOURCE_PATH),
userQuota = quota(state = UserQuotaState.CRITICAL),
).storageWarning
)
assertEquals(
AutomaticUploadStorageWarning.EXCEEDED,
AutomaticUploadStatusFormatter.statusFor(
configuration = backupConfiguration(),
sourcePaths = listOf(DEFAULT_SOURCE_PATH),
userQuota = quota(available = 0L, state = UserQuotaState.NORMAL),
).storageWarning
)
}
@Test
fun `transfer summary counts only matching automatic upload type`() {
val summary = AutomaticUploadTransferSummary.fromTransfers(
transfers = listOf(
transfer(TransferStatus.TRANSFER_IN_PROGRESS, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
transfer(TransferStatus.TRANSFER_QUEUED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
transfer(TransferStatus.TRANSFER_FAILED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
transfer(TransferStatus.TRANSFER_FAILED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO),
transfer(TransferStatus.TRANSFER_QUEUED, UploadEnqueuedBy.ENQUEUED_BY_USER),
transfer(TransferStatus.TRANSFER_SUCCEEDED, UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE),
),
createdBy = UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE,
)
assertEquals(
AutomaticUploadTransferSummary(
inProgressCount = 1,
queuedCount = 1,
failedCount = 1,
),
summary
)
}
@Test
fun `pause reasons are empty when no uploads are queued`() {
val reasons = AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = backupConfiguration().copy(wifiOnly = true, chargingOnly = true),
transferSummary = AutomaticUploadTransferSummary(
inProgressCount = 1,
queuedCount = 0,
failedCount = 1,
),
isActiveNetworkMetered = true,
isActiveNetworkRoaming = true,
isCharging = false,
)
assertEquals(emptySet<AutomaticUploadPauseReason>(), reasons)
}
@Test
fun `pause reasons report unmet queued upload constraints`() {
val reasons = AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = backupConfiguration().copy(
wifiOnly = false,
allowRoaming = false,
chargingOnly = true,
),
transferSummary = AutomaticUploadTransferSummary(
inProgressCount = 0,
queuedCount = 2,
failedCount = 0,
),
isActiveNetworkMetered = false,
isActiveNetworkRoaming = true,
isCharging = false,
)
assertEquals(
linkedSetOf(
AutomaticUploadPauseReason.WAITING_FOR_NOT_ROAMING_NETWORK,
AutomaticUploadPauseReason.WAITING_FOR_CHARGING,
),
reasons
)
}
@Test
fun `pause reasons report wifi-only queued upload waiting for unmetered network`() {
val reasons = AutomaticUploadStatusFormatter.resolvePauseReasons(
configuration = backupConfiguration().copy(wifiOnly = true),
transferSummary = AutomaticUploadTransferSummary(
inProgressCount = 0,
queuedCount = 1,
failedCount = 0,
),
isActiveNetworkMetered = true,
isActiveNetworkRoaming = false,
isCharging = true,
)
assertEquals(
setOf(AutomaticUploadPauseReason.WAITING_FOR_UNMETERED_NETWORK),
reasons
)
}
private fun backupConfiguration(
sourcePaths: List<String> = listOf(DEFAULT_SOURCE_PATH),
sourceSyncTimestamps: Map<String, Long> = emptyMap(),
lastSyncTimestamp: Long = 0L,
): FolderBackUpConfiguration =
FolderBackUpConfiguration(
accountName = ACCOUNT_NAME,
behavior = UploadBehavior.COPY,
sourcePath = encodeSourcePaths(sourcePaths),
uploadPath = "/Photos",
wifiOnly = true,
allowRoaming = false,
mobileDataLimitBytes = 0L,
mobileDataPeriodStartTimestamp = 0L,
mobileDataBytesQueuedInPeriod = 0L,
chargingOnly = false,
lastSyncTimestamp = lastSyncTimestamp,
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(sourceSyncTimestamps),
name = FolderBackUpConfiguration.pictureUploadsName,
spaceId = null,
)
private fun quota(
available: Long = 1_000L,
state: UserQuotaState = UserQuotaState.NORMAL,
): UserQuota =
UserQuota(
accountName = ACCOUNT_NAME,
available = available,
used = 1_000L,
total = 2_000L,
state = state,
)
private fun transfer(
status: TransferStatus,
createdBy: UploadEnqueuedBy,
): OCTransfer =
OCTransfer(
localPath = "content://local/${status.name}/${createdBy.name}",
remotePath = "${File.separator}Photos${File.separator}${status.name}-${createdBy.name}.jpg",
accountName = ACCOUNT_NAME,
fileSize = 1L,
status = status,
localBehaviour = UploadBehavior.COPY,
forceOverwrite = false,
createdBy = createdBy,
)
private companion object {
const val ACCOUNT_NAME = "user@example.com"
const val DEFAULT_SOURCE_PATH = "content://camera"
}
}
@@ -23,6 +23,7 @@ class PublicShareStatusFormatterTest {
assertEquals(PublicShareAccessMode.READ_ONLY, status.accessMode)
assertFalse(status.isPasswordProtected)
assertNull(status.expirationDateInMillis)
assertTrue(status.isEditableInOcsDialog)
}
@Test
@@ -62,5 +63,6 @@ class PublicShareStatusFormatterTest {
val status = PublicShareStatusFormatter.statusFor(share)
assertEquals(PublicShareAccessMode.CUSTOM, status.accessMode)
assertFalse(status.isEditableInOcsDialog)
}
}
+85 -1
View File
@@ -9,8 +9,10 @@ param(
[string]$Notes,
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/QSfera.git',
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[string]$GitProvenanceRoot,
[switch]$Unlisted,
[switch]$SkipSigningInitialization
[switch]$SkipSigningInitialization,
[switch]$SkipGitProvenanceCheck
)
$ErrorActionPreference = 'Stop'
@@ -112,8 +114,83 @@ function Find-FirstTool([string]$AndroidHome, [string]$ToolName) {
return $tool.FullName
}
function Invoke-Git([string[]]$Arguments) {
$output = & git @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
throw "git $($Arguments -join ' ') failed with exit code $LASTEXITCODE. Output: $($output | Out-String)"
}
return @($output)
}
function Assert-GitReleaseProvenance([string]$RepositoryRoot) {
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'git was not found. Argus publication requires git provenance verification.'
}
$previousLocation = Get-Location
try {
Set-Location $RepositoryRoot
$insideWorkTree = (Invoke-Git @('rev-parse', '--is-inside-work-tree') | Select-Object -First 1).Trim()
if ($insideWorkTree -ne 'true') {
throw 'Argus publication must be prepared from inside a git work tree.'
}
$status = Invoke-Git @('status', '--porcelain')
if ($status.Count -gt 0) {
throw "Git work tree has uncommitted changes. Commit or stash all changes before preparing Argus publication."
}
$branch = (Invoke-Git @('rev-parse', '--abbrev-ref', 'HEAD') | Select-Object -First 1).Trim()
if ($branch -eq 'HEAD') {
throw 'Git HEAD is detached. Check out the release branch before preparing Argus publication.'
}
try {
$upstream = (Invoke-Git @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') | Select-Object -First 1).Trim()
} catch {
throw "Git branch has no upstream. Push $branch and set its upstream before Argus publication."
}
Invoke-Git @('fetch', '--quiet')
$aheadBehind = (Invoke-Git @('rev-list', '--left-right', '--count', 'HEAD...@{u}') | Select-Object -First 1).Trim() -split '\s+'
$ahead = [int]$aheadBehind[0]
$behind = [int]$aheadBehind[1]
if ($ahead -ne 0 -or $behind -ne 0) {
throw "Git HEAD must match upstream before Argus publication. Branch $branch tracks $upstream, ahead=$ahead, behind=$behind."
}
return [ordered]@{
repositoryRoot = (Invoke-Git @('rev-parse', '--show-toplevel') | Select-Object -First 1).Trim()
branch = $branch
upstream = $upstream
commit = (Invoke-Git @('rev-parse', 'HEAD') | Select-Object -First 1).Trim()
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
}
} finally {
Set-Location $previousLocation
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$gitProvenanceRootPath = if ($GitProvenanceRoot) {
(Resolve-Path -LiteralPath $GitProvenanceRoot).Path
} else {
$repoRoot
}
$gitProvenance = if ($SkipGitProvenanceCheck) {
[ordered]@{
skipped = $true
repositoryRoot = $gitProvenanceRootPath
reason = 'SkipGitProvenanceCheck was set'
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
}
} else {
Assert-GitReleaseProvenance $gitProvenanceRootPath
}
$javaHome = Resolve-JavaHome
$androidHome = Resolve-AndroidHome
@@ -237,6 +314,8 @@ if (-not (Test-Path $stageDirectory)) {
$stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode
$stagedApkPath = Join-Path $stageDirectory $stagedApkName
Copy-Item $releaseApkPath $stagedApkPath -Force
$stagedApkSize = (Get-Item -LiteralPath $stagedApkPath).Length
$stagedApkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $stagedApkPath).Hash.ToLowerInvariant()
$metadata = [ordered]@{
packagePath = $stagedApkPath
@@ -251,6 +330,11 @@ $metadata = [ordered]@{
notes = if ($Notes) { $Notes } else { $null }
repositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { $null }
homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null }
packageSizeBytes = $stagedApkSize
sha256 = $stagedApkSha256
publicationMethod = 'ssh-sqlite-data-packages'
publicationInstruction = 'G:/Repos/Marketplace/ARGUS_PUBLICATION.md'
git = $gitProvenance
androidPackageName = $packageName
androidVersionCode = [int]$versionCode
targetSdkVersion = [int]$targetSdk
+353 -122
View File
@@ -1,141 +1,372 @@
param(
[string]$BaseUrl = 'https://argus.kusoft.xyz',
[Parameter(Mandatory = $true)]
[string]$Username,
[Parameter(Mandatory = $true)]
[string]$Password,
[Parameter(Mandatory = $true)]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Summary,
[Parameter(Mandatory = $true)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$AndroidPackageName,
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[ValidateRange(1, [int]::MaxValue)]
[int]$AndroidVersionCode,
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[string]$Channel = 'stable',
[string]$Platform = 'android',
[string]$PackageKind = 'apk',
[string]$Notes,
[string]$RepositoryUrl,
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[switch]$Unlisted
[string]$MetadataPath,
[string]$PublicBaseUrl = 'https://argus.kusoft.xyz',
[string]$RemoteHost = '192.168.0.185',
[string]$RemoteUser = 'sevenhill',
[int]$RemotePort = 22,
[string]$RemoteTempDirectory = '/tmp',
[string]$ArgusData = '/srv/argus-data',
[string]$GeneratedScriptPath,
[switch]$PrepareOnly,
[switch]$RestartArgus
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Net.Http
function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) {
if ($null -eq $Response.Content) {
return ''
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Get-LatestMetadataPath {
$repoRoot = Split-Path -Parent $PSScriptRoot
$publicationRoot = Join-Path $repoRoot 'build\argus-publication'
if (-not (Test-Path -LiteralPath $publicationRoot)) {
Fail "Argus publication directory was not found: $publicationRoot"
}
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Ensure-Success(
[System.Net.Http.HttpResponseMessage]$Response,
[string]$Operation
) {
if ($Response.IsSuccessStatusCode) {
return
$metadata = Get-ChildItem -LiteralPath $publicationRoot -Recurse -File -Filter 'argus-publication.json' |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $metadata) {
Fail "No argus-publication.json file was found under $publicationRoot"
}
$body = Read-HttpBody $Response
throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body"
return $metadata.FullName
}
function New-JsonContent([string]$Json) {
return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json')
function Get-RequiredString($Object, [string]$Name) {
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
Fail "Publication metadata is missing '$Name'."
}
return [string]$property.Value
}
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) {
throw "Package file was not found: $resolvedPackagePath"
function Quote-BashSingle([string]$Value) {
if ($null -eq $Value) {
$Value = ''
}
return "'" + ($Value -replace "'", "'\''") + "'"
}
$baseUri = $BaseUrl.TrimEnd('/')
$cookieContainer = [System.Net.CookieContainer]::new()
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.CookieContainer = $cookieContainer
$handler.UseCookies = $true
$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromMinutes(15)
function Join-RemotePath([string]$Directory, [string]$Leaf) {
return $Directory.TrimEnd('/') + '/' + $Leaf
}
function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) {
& $Program @Arguments
if ($LASTEXITCODE -ne 0) {
Fail "$Program failed with exit code $LASTEXITCODE."
}
}
function Assert-Slug([string]$Slug) {
if ($Slug -notmatch '^[a-z0-9][a-z0-9-]{0,99}$') {
Fail "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $Slug"
}
}
function Assert-ManifestValue($Manifest, [string]$Name, $Actual, $Expected) {
if ([string]$Actual -ne [string]$Expected) {
Fail "Published manifest $Name expected '$Expected' but got '$Actual'."
}
}
if ([string]::IsNullOrWhiteSpace($MetadataPath)) {
$MetadataPath = Get-LatestMetadataPath
}
$resolvedMetadataPath = (Resolve-Path -LiteralPath $MetadataPath).Path
$metadata = Get-Content -LiteralPath $resolvedMetadataPath -Raw -Encoding UTF8 | ConvertFrom-Json
$packagePath = (Resolve-Path -LiteralPath (Get-RequiredString $metadata 'packagePath')).Path
$slug = Get-RequiredString $metadata 'slug'
$name = Get-RequiredString $metadata 'name'
$summary = Get-RequiredString $metadata 'summary'
$description = Get-RequiredString $metadata 'description'
$version = Get-RequiredString $metadata 'version'
$channel = (Get-RequiredString $metadata 'channel').ToLowerInvariant()
$platform = (Get-RequiredString $metadata 'platform').ToLowerInvariant()
$packageKind = (Get-RequiredString $metadata 'packageKind').ToLowerInvariant()
$notes = if ($metadata.PSObject.Properties['notes']) { [string]$metadata.notes } else { '' }
$repositoryUrl = if ($metadata.PSObject.Properties['repositoryUrl']) { [string]$metadata.repositoryUrl } else { '' }
$homepageUrl = if ($metadata.PSObject.Properties['homepageUrl']) { [string]$metadata.homepageUrl } else { '' }
$isListed = if ($metadata.PSObject.Properties['isListed'] -and [bool]$metadata.isListed) { '1' } else { '0' }
Assert-Slug $slug
if ($platform -ne 'android') {
Fail "QSfera Android publication requires platform=android. Actual platform: $platform"
}
if ($packageKind -ne 'apk') {
Fail "QSfera Android publication requires packageKind=apk. Actual packageKind: $packageKind"
}
if (-not $packagePath.EndsWith('.apk', [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "QSfera Android publication requires an APK package: $packagePath"
}
$scp = Get-Command scp -ErrorAction SilentlyContinue
$ssh = Get-Command ssh -ErrorAction SilentlyContinue
if (-not $scp -or -not $ssh) {
Fail 'Both scp and ssh must be available for SSH-only Argus publication.'
}
$localSize = (Get-Item -LiteralPath $packagePath).Length
$localSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $packagePath).Hash.ToLowerInvariant()
$packageFileName = Split-Path -Leaf $packagePath
$remotePackagePath = Join-RemotePath $RemoteTempDirectory $packageFileName
$token = [guid]::NewGuid().ToString('N')
$remoteScriptPath = Join-RemotePath $RemoteTempDirectory "$slug-publish-$token.sh"
$localScriptPath = if ([string]::IsNullOrWhiteSpace($GeneratedScriptPath)) {
Join-Path ([System.IO.Path]::GetTempPath()) "$slug-publish-$token.sh"
} else {
$GeneratedScriptPath
}
$remote = "$RemoteUser@$RemoteHost"
$sshPort = [string]$RemotePort
$restartCommand = if ($RestartArgus) {
'docker restart argus >/dev/null'
} else {
':'
}
$scriptTemplate = @'
set -euo pipefail
cleanup() {
rm -f "$SOURCE_FILE" "$0"
}
trap cleanup EXIT
export ARGUS_DATA=__ARGUS_DATA__
export SOURCE_FILE=__SOURCE_FILE__
export ARGUS_SLUG=__ARGUS_SLUG__
export ARGUS_NAME=__ARGUS_NAME__
export ARGUS_SUMMARY=__ARGUS_SUMMARY__
export ARGUS_DESCRIPTION=__ARGUS_DESCRIPTION__
export ARGUS_REPOSITORY_URL=__ARGUS_REPOSITORY_URL__
export ARGUS_HOMEPAGE_URL=__ARGUS_HOMEPAGE_URL__
export ARGUS_IS_LISTED=__ARGUS_IS_LISTED__
export ARGUS_VERSION=__ARGUS_VERSION__
export ARGUS_CHANNEL=__ARGUS_CHANNEL__
export ARGUS_PLATFORM=__ARGUS_PLATFORM__
export ARGUS_PACKAGE_KIND=__ARGUS_PACKAGE_KIND__
export ARGUS_NOTES=__ARGUS_NOTES__
python3 - "$SOURCE_FILE" <<'PY'
import hashlib
import mimetypes
import os
import re
import shutil
import sqlite3
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
source = Path(sys.argv[1]).resolve()
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
slug = os.environ["ARGUS_SLUG"].strip()
name = os.environ["ARGUS_NAME"].strip()
summary = os.environ["ARGUS_SUMMARY"].strip()
description = os.environ["ARGUS_DESCRIPTION"].strip()
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
version = os.environ["ARGUS_VERSION"].strip()
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
notes = os.environ.get("ARGUS_NOTES", "").strip() or None
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
if not source.is_file():
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
if not db_path.is_file():
raise SystemExit(f"Argus database does not exist: {db_path}")
for key, value in {
"ARGUS_NAME": name,
"ARGUS_SUMMARY": summary,
"ARGUS_DESCRIPTION": description,
"ARGUS_VERSION": version,
}.items():
if not value:
raise SystemExit(f"{key} is required.")
release_id = str(uuid.uuid4()).upper()
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
extension = source.suffix or ".bin"
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
stored_relative_path = f"{slug}/{stored_name}"
target_dir = packages_root / slug
target_path = target_dir / stored_name
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
if source.suffix.lower() == ".apk":
content_type = "application/vnd.android.package-archive"
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
if row is None:
app_id = str(uuid.uuid4()).upper()
conn.execute(
'''
INSERT INTO "Apps"
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
)
else:
app_id = row[0]
conn.execute(
'''
UPDATE "Apps"
SET "Name" = ?,
"Summary" = ?,
"Description" = ?,
"RepositoryUrl" = ?,
"HomepageUrl" = ?,
"IsListed" = ?,
"UpdatedAt" = ?
WHERE "Id" = ?
''',
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
)
duplicate = conn.execute(
'''
SELECT "Id"
FROM "Releases"
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
''',
(app_id, version, channel, platform),
).fetchone()
if duplicate is not None:
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
conn.execute(
'''
INSERT INTO "Releases"
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
"Sha256", "Notes", "PublishedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
release_id,
app_id,
version,
channel,
platform,
package_kind,
source.name,
stored_relative_path,
content_type,
size_bytes,
sha256,
notes,
now,
),
)
conn.commit()
except Exception:
conn.rollback()
try:
target_path.unlink()
except FileNotFoundError:
pass
raise
finally:
conn.close()
print(f"Published app={slug} version={version} channel={channel} platform={platform}")
print(f"StoredRelativePath={stored_relative_path}")
print(f"Size={size_bytes}")
print(f"Sha256={sha256}")
print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
PY
curl -fsS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
__RESTART_COMMAND__
'@
$publicationScript = $scriptTemplate.
Replace('__ARGUS_DATA__', (Quote-BashSingle $ArgusData)).
Replace('__SOURCE_FILE__', (Quote-BashSingle $remotePackagePath)).
Replace('__ARGUS_SLUG__', (Quote-BashSingle $slug)).
Replace('__ARGUS_NAME__', (Quote-BashSingle $name)).
Replace('__ARGUS_SUMMARY__', (Quote-BashSingle $summary)).
Replace('__ARGUS_DESCRIPTION__', (Quote-BashSingle $description)).
Replace('__ARGUS_REPOSITORY_URL__', (Quote-BashSingle $repositoryUrl)).
Replace('__ARGUS_HOMEPAGE_URL__', (Quote-BashSingle $homepageUrl)).
Replace('__ARGUS_IS_LISTED__', (Quote-BashSingle $isListed)).
Replace('__ARGUS_VERSION__', (Quote-BashSingle $version)).
Replace('__ARGUS_CHANNEL__', (Quote-BashSingle $channel)).
Replace('__ARGUS_PLATFORM__', (Quote-BashSingle $platform)).
Replace('__ARGUS_PACKAGE_KIND__', (Quote-BashSingle $packageKind)).
Replace('__ARGUS_NOTES__', (Quote-BashSingle $notes)).
Replace('__RESTART_COMMAND__', $restartCommand)
[System.IO.File]::WriteAllText($localScriptPath, $publicationScript, [System.Text.UTF8Encoding]::new($false))
if ($PrepareOnly) {
Write-Output 'QSfera Android Argus SSH publication script prepared.'
Write-Output "Script: $localScriptPath"
Write-Output "APK: $packagePath"
Write-Output "Remote APK: $remotePackagePath"
Write-Output "Size: $localSize"
Write-Output "SHA-256: $localSha256"
exit 0
}
try {
$loginPayload = @{
username = $Username
password = $Password
} | ConvertTo-Json -Compress
$loginResponse = $client.PostAsync(
"$baseUri/api/admin/session/login",
(New-JsonContent $loginPayload)
).GetAwaiter().GetResult()
Ensure-Success $loginResponse 'Argus login'
$appPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl }
homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl }
isListed = (-not $Unlisted)
} | ConvertTo-Json -Compress
$appResponse = $client.PutAsync(
"$baseUri/api/admin/apps/$Slug",
(New-JsonContent $appPayload)
).GetAwaiter().GetResult()
Ensure-Success $appResponse 'Argus app metadata update'
$form = [System.Net.Http.MultipartFormDataContent]::new()
$fields = [ordered]@{
Version = $Version
Channel = $Channel
Platform = $Platform
PackageKind = $PackageKind
AndroidVersionCode = [string]$AndroidVersionCode
}
foreach ($entry in $fields.GetEnumerator()) {
$form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key)
}
if (-not [string]::IsNullOrWhiteSpace($Notes)) {
$form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes')
}
$fileStream = [System.IO.File]::OpenRead($resolvedPackagePath)
try {
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath))
$releaseResponse = $client.PostAsync(
"$baseUri/api/admin/apps/$Slug/releases",
$form
).GetAwaiter().GetResult()
Ensure-Success $releaseResponse 'Argus release upload'
$releaseBody = Read-HttpBody $releaseResponse
if (-not [string]::IsNullOrWhiteSpace($releaseBody)) {
Write-Output $releaseBody
}
} finally {
$fileStream.Dispose()
$form.Dispose()
}
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $packagePath, "${remote}:$remotePackagePath")
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $localScriptPath, "${remote}:$remoteScriptPath")
Invoke-CheckedNative $ssh.Source @('-p', $sshPort, $remote, "bash $(Quote-BashSingle $remoteScriptPath)")
} finally {
$client.Dispose()
$handler.Dispose()
Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue
}
$manifestUrl = "$($PublicBaseUrl.TrimEnd('/'))/api/apps/$slug/manifest?platform=$platform&channel=$channel"
$manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 30
$release = $manifest.release
if ($null -eq $release) {
Fail "Published manifest did not return a release: $manifestUrl"
}
Assert-ManifestValue $manifest 'slug' $manifest.slug $slug
Assert-ManifestValue $release 'version' $release.version $version
Assert-ManifestValue $release 'channel' $release.channel $channel
Assert-ManifestValue $release 'platform' $release.platform $platform
Assert-ManifestValue $release 'packageKind' $release.packageKind $packageKind
Assert-ManifestValue $release 'originalFileName' $release.originalFileName $packageFileName
Assert-ManifestValue $release 'packageSizeBytes' $release.packageSizeBytes $localSize
Assert-ManifestValue $release 'sha256' $release.sha256 $localSha256
Write-Output 'QSfera Android Argus SSH publication completed.'
Write-Output "Manifest: $manifestUrl"
Write-Output "Size: $localSize"
Write-Output "SHA-256: $localSha256"
@@ -0,0 +1,183 @@
param(
[string]$PropertiesPath = 'argus-signing.local.properties',
[string]$ExpectedCertificateSha256 = 'ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877',
[string]$PublishedApkUrl = 'https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable',
[switch]$VerifyPublishedApk
)
$ErrorActionPreference = 'Stop'
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Read-Properties([string]$Path) {
$result = @{}
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
continue
}
$separator = $trimmed.IndexOf('=')
if ($separator -le 0) {
continue
}
$key = $trimmed.Substring(0, $separator).Trim()
$value = $trimmed.Substring($separator + 1).Trim()
$result[$key] = $value
}
return $result
}
function Resolve-JavaHome {
$candidates = @(
$env:JAVA_HOME,
'C:\Program Files\Android\Android Studio\jbr',
'C:\Program Files\Android\openjdk\jdk-21.0.8'
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
$keytool = Join-Path $candidate 'bin\keytool.exe'
if (Test-Path -LiteralPath $keytool) {
return $candidate
}
}
Fail 'keytool.exe was not found. Set JAVA_HOME to a JDK path.'
}
function Resolve-AndroidHome {
$candidates = @(
$env:ANDROID_HOME,
"$env:LOCALAPPDATA\Android\Sdk"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath (Join-Path $candidate 'build-tools')) {
return $candidate
}
}
Fail 'Android SDK was not found. Set ANDROID_HOME to the SDK path.'
}
function Find-FirstTool([string]$AndroidHome, [string]$ToolName) {
$tool = Get-ChildItem -Path (Join-Path $AndroidHome 'build-tools') -Recurse -File -Filter $ToolName |
Sort-Object FullName -Descending |
Select-Object -First 1
if (-not $tool) {
Fail "$ToolName was not found under Android build-tools."
}
return $tool.FullName
}
function Normalize-Sha256([string]$Value) {
return ($Value -replace '[^0-9a-fA-F]', '').ToLowerInvariant()
}
function Get-KeytoolCertificateSha256([string]$KeytoolPath, [string]$KeystorePath, [string]$Password, [string]$Alias) {
$env:QSFERA_VERIFY_KEYSTORE_PASSWORD = $Password
try {
$output = & $KeytoolPath -list -v -keystore $KeystorePath -storepass:env QSFERA_VERIFY_KEYSTORE_PASSWORD -alias $Alias 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "keytool could not read the release keystore alias '$Alias'. Output: $($output | Out-String)"
}
} finally {
Remove-Item Env:\QSFERA_VERIFY_KEYSTORE_PASSWORD -ErrorAction SilentlyContinue
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA256:\s*([0-9A-Fa-f:]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA256 certificate fingerprint from keytool output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
function Get-ApkCertificateSha256([string]$ApksignerPath, [string]$ApkPath) {
$output = & $ApksignerPath verify --print-certs $ApkPath 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "apksigner could not verify APK certificate. Output: $($output | Out-String)"
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA-256 digest:\s*([0-9A-Fa-f]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA-256 certificate fingerprint from apksigner output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) {
$PropertiesPath
} else {
Join-Path $repoRoot $PropertiesPath
}
if (-not (Test-Path -LiteralPath $resolvedPropertiesPath)) {
Fail "Signing properties were not found: $resolvedPropertiesPath"
}
$properties = Read-Properties $resolvedPropertiesPath
foreach ($key in @(
'QSFERA_RELEASE_KEYSTORE',
'QSFERA_RELEASE_KEYSTORE_PASSWORD',
'QSFERA_RELEASE_KEY_ALIAS',
'QSFERA_RELEASE_KEY_PASSWORD'
)) {
if (-not $properties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($properties[$key])) {
Fail "Signing property is missing: $key"
}
}
$keystorePath = $properties['QSFERA_RELEASE_KEYSTORE']
if (-not [System.IO.Path]::IsPathRooted($keystorePath)) {
$keystorePath = Join-Path $repoRoot $keystorePath
}
if (-not (Test-Path -LiteralPath $keystorePath)) {
Fail "Release keystore was not found: $keystorePath"
}
if ((Get-Item -LiteralPath $keystorePath).Length -le 0) {
Fail "Release keystore is empty: $keystorePath"
}
$javaHome = Resolve-JavaHome
$env:JAVA_HOME = $javaHome
$keytoolPath = Join-Path $javaHome 'bin\keytool.exe'
$expectedSha256 = Normalize-Sha256 $ExpectedCertificateSha256
$alias = $properties['QSFERA_RELEASE_KEY_ALIAS']
$keytoolSha256 = Get-KeytoolCertificateSha256 `
-KeytoolPath $keytoolPath `
-KeystorePath $keystorePath `
-Password $properties['QSFERA_RELEASE_KEYSTORE_PASSWORD'] `
-Alias $alias
if ($keytoolSha256 -ne $expectedSha256) {
Fail "Release keystore certificate SHA-256 expected $expectedSha256 but got $keytoolSha256."
}
Write-Output "OK: release signing properties are complete: $resolvedPropertiesPath"
Write-Output "OK: release keystore exists and is readable: $keystorePath"
Write-Output "OK: release key alias '$alias' certificate SHA-256 = $keytoolSha256"
if ($VerifyPublishedApk) {
$androidHome = Resolve-AndroidHome
$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat'
$tempApk = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), '.apk')
try {
Invoke-WebRequest -Uri $PublishedApkUrl -UseBasicParsing -TimeoutSec 120 -OutFile $tempApk | Out-Null
$publishedSha256 = Get-ApkCertificateSha256 -ApksignerPath $apksignerPath -ApkPath $tempApk
if ($publishedSha256 -ne $expectedSha256) {
Fail "Published APK certificate SHA-256 expected $expectedSha256 but got $publishedSha256."
}
Write-Output "OK: published Argus APK certificate SHA-256 = $publishedSha256"
} finally {
Remove-Item -LiteralPath $tempApk -Force -ErrorAction SilentlyContinue
}
}
+7 -1
View File
@@ -53,6 +53,7 @@
#include <QGuiApplication>
#include <QMenuBar>
#include <QScreen>
#include <QWindow>
using namespace Qt::Literals::StringLiterals;
using namespace OCC;
@@ -283,6 +284,9 @@ void Application::showSettings()
ensureWindowIntersectsAvailableScreen(window);
window->raise();
window->activateWindow();
if (auto *windowHandle = window->windowHandle()) {
windowHandle->requestActivate();
}
#if defined(Q_OS_WIN)
// Windows disallows raising a Window when you're not the active application.
@@ -294,7 +298,9 @@ void Application::showSettings()
foregroundThreadId != 0 && foregroundThreadId != currentThreadId && AttachThreadInput(currentThreadId, foregroundThreadId, true);
const auto hwnd = reinterpret_cast<HWND>(window->winId());
ShowWindow(hwnd, SW_RESTORE);
ShowWindow(hwnd, IsIconic(hwnd) ? SW_RESTORE : SW_SHOW);
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
SetForegroundWindow(hwnd);
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
+4 -2
View File
@@ -24,17 +24,19 @@
#include <QApplication>
#include <QDesktopServices>
#include <QMenu>
#include <QMetaObject>
#include <QTimer>
using namespace Qt::Literals::StringLiterals;
namespace OCC {
namespace {
constexpr auto showSettingsAfterTrayEventDelay = 100;
void showSettingsAfterTrayEvent()
{
auto *app = ocApp();
QMetaObject::invokeMethod(app, [app] { app->showSettings(); }, Qt::QueuedConnection);
QTimer::singleShot(showSettingsAfterTrayEventDelay, app, [app] { app->showSettings(); });
}
}
+4 -10
View File
@@ -37,17 +37,11 @@ go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.j
## Publish to Argus
The Argus publisher script uses the admin endpoints exposed by `https://argus.kusoft.xyz/site.js`:
Argus publication follows `G:\Repos\Marketplace\ARGUS_PUBLICATION.md`: copy the APK over SSH, then insert release metadata into `/srv/argus-data/argus.db` on the Argus host. The app must only use the public read-only manifest API for updates.
* `POST /api/admin/session/login`
* `PUT /api/admin/apps/{slug}`
* `POST /api/admin/apps/{slug}/releases`
Set credentials in the current shell, then run:
Build the APK, then run:
```powershell
$env:ARGUS_USERNAME='admin'
$env:ARGUS_PASSWORD='<password>'
.\scripts\publish-argus.ps1
```
@@ -57,6 +51,6 @@ Default publish metadata:
* package: `eu.qsfera.keeper`
* name: `QKeep`
* Android label: `КуЗаметки`
* version: `0.1.21`
* Android version code: `22`
* version: `0.1.22`
* Android version code: `23`
* channel: `stable`
+2 -2
View File
@@ -23,8 +23,8 @@ android {
applicationId "eu.qsfera.keeper"
minSdkVersion sdkMinVersion
targetSdkVersion sdkTargetVersion
versionCode 22
versionName "0.1.21"
versionCode 23
versionName "0.1.22"
def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes"
def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz"
@@ -161,6 +161,7 @@ enum class BodyTextStyle {
class MainActivity : AppCompatActivity() {
private lateinit var authPreferences: AuthPreferences
private lateinit var keepSettings: KeepSettings
private lateinit var versionCleanup: VersionCleanup
private lateinit var authClient: QsferaAuthClient
private lateinit var repository: NotesRepository
private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL)
@@ -183,10 +184,11 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
authPreferences = AuthPreferences(applicationContext)
keepSettings = KeepSettings(applicationContext)
versionCleanup = VersionCleanup(applicationContext)
authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL)
repository = NotesRepository(applicationContext, authPreferences, authClient)
configureSystemBars()
VersionCleanup(applicationContext).cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME)
versionCleanup.cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME)
ReminderScheduler(applicationContext).ensureNotificationChannel()
ensureNotificationPermission()
@@ -799,12 +801,15 @@ class MainActivity : AppCompatActivity() {
checkButton.isEnabled = true
updateStatus.text = result.fold(
onSuccess = { latest ->
if (latest.androidVersionCode > BuildConfig.VERSION_CODE) {
val installedVersion = versionCleanup.installedVersionName(BuildConfig.VERSION_NAME)
if (latest == null) {
getString(R.string.no_update_available)
} else if (AppVersionComparator.isRemoteNewer(installedVersion, latest.version)) {
availableUpdate = latest
installButton.visibility = View.VISIBLE
getString(R.string.update_available, latest.version, latest.androidVersionCode)
getString(R.string.update_available, latest.version)
} else {
getString(R.string.up_to_date, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
getString(R.string.up_to_date, installedVersion)
}
},
onFailure = {
@@ -827,6 +832,7 @@ class MainActivity : AppCompatActivity() {
installButton.isEnabled = true
result
.onSuccess { apk ->
versionCleanup.markUpdateStaged(update.version)
updateStatus.text = getString(R.string.opening_installer)
installApk(apk)
}
@@ -3099,13 +3105,13 @@ data class PendingOAuth(
data class AppUpdateInfo(
val version: String,
val androidVersionCode: Int,
val downloadUrl: String,
val sha256: String?,
val sha256: String,
val packageSizeBytes: Long?,
)
class AppUpdateClient(private val manifestUrl: String) {
fun latest(): AppUpdateInfo {
fun latest(): AppUpdateInfo? {
val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 8000
@@ -3116,16 +3122,22 @@ class AppUpdateClient(private val manifestUrl: String) {
return try {
val response = if (connection.responseCode in 200..299) {
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
return null
} else {
val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
throw IOException("HTTP ${connection.responseCode}: $errorText")
}
val release = JSONObject(response).getJSONObject("release")
val sha256 = release.optString("sha256").trim()
if (!sha256.matches(Regex("[0-9a-fA-F]{64}"))) {
throw IOException("Argus release sha256 is missing or invalid")
}
AppUpdateInfo(
version = release.getString("version"),
androidVersionCode = release.getInt("androidVersionCode"),
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
sha256 = release.optString("sha256").takeIf { it.isNotBlank() }
sha256 = sha256,
packageSizeBytes = release.optLong("packageSizeBytes", -1L).takeIf { it >= 0L }
)
} finally {
connection.disconnect()
@@ -3139,7 +3151,10 @@ class AppUpdateClient(private val manifestUrl: String) {
updateDir.mkdirs()
}
val apk = File(updateDir, "keeper-android-${update.version}.apk")
val tempApk = File(updateDir, "keeper-android-${update.version}.apk.download")
tempApk.delete()
val digest = MessageDigest.getInstance("SHA-256")
var downloadedBytes = 0L
val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 8000
@@ -3153,25 +3168,38 @@ class AppUpdateClient(private val manifestUrl: String) {
throw IOException("HTTP ${connection.responseCode}: $errorText")
}
connection.inputStream.use { input ->
apk.outputStream().use { output ->
tempApk.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
output.write(buffer, 0, read)
downloadedBytes += read
}
}
}
} catch (error: Exception) {
tempApk.delete()
throw error
} finally {
connection.disconnect()
}
val actualSha256 = digest.digest().toHexString()
val expectedSha256 = update.sha256
if (expectedSha256 != null && !expectedSha256.equals(actualSha256, ignoreCase = true)) {
apk.delete()
if (!update.sha256.equals(actualSha256, ignoreCase = true)) {
tempApk.delete()
throw IOException("Downloaded APK checksum mismatch")
}
val expectedSize = update.packageSizeBytes
if (expectedSize != null && expectedSize != downloadedBytes) {
tempApk.delete()
throw IOException("Downloaded APK size mismatch")
}
apk.delete()
if (!tempApk.renameTo(apk)) {
tempApk.copyTo(apk, overwrite = true)
tempApk.delete()
}
return apk
}
@@ -3184,6 +3212,32 @@ class AppUpdateClient(private val manifestUrl: String) {
}
}
object AppVersionComparator {
private val semanticVersionPattern = Regex("""^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""")
fun isRemoteNewer(installedVersion: String, remoteVersion: String): Boolean {
val installedSemanticVersion = installedVersion.toSemanticVersionParts()
val remoteSemanticVersion = remoteVersion.toSemanticVersionParts()
if (installedSemanticVersion != null && remoteSemanticVersion != null) {
installedSemanticVersion.indices.forEach { index ->
val comparison = remoteSemanticVersion[index].compareTo(installedSemanticVersion[index])
if (comparison != 0) return comparison > 0
}
return false
}
return remoteVersion.compareTo(installedVersion, ignoreCase = true) > 0
}
private fun String.toSemanticVersionParts(): List<Int>? {
val match = semanticVersionPattern.matchEntire(trim()) ?: return null
return listOf(
match.groupValues[1].toInt(),
match.groupValues[2].toInt(),
match.groupValues[3].toInt()
)
}
}
class VersionCleanup(private val context: Context) {
private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE)
@@ -3193,10 +3247,26 @@ class VersionCleanup(private val context: Context) {
cleanupOldPackageFiles(currentVersionName)
preferences.edit()
.putInt(KEY_LAST_VERSION_CODE, currentVersionCode)
.putString(KEY_INSTALLED_VERSION_NAME, currentVersionName)
.apply()
} else if (preferences.getString(KEY_INSTALLED_VERSION_NAME, null) != currentVersionName) {
preferences.edit()
.putString(KEY_INSTALLED_VERSION_NAME, currentVersionName)
.apply()
}
}
fun installedVersionName(defaultVersionName: String): String {
return preferences.getString(KEY_INSTALLED_VERSION_NAME, null) ?: defaultVersionName
}
fun markUpdateStaged(versionName: String) {
preferences.edit()
.putString(KEY_STAGED_VERSION_NAME, versionName)
.putLong(KEY_STAGED_AT, System.currentTimeMillis())
.apply()
}
fun cleanupOldPackageFiles(currentVersionName: String) {
packageFileRoots().forEach { root ->
if (!root.exists()) return@forEach
@@ -3229,6 +3299,9 @@ class VersionCleanup(private val context: Context) {
private companion object {
const val KEY_LAST_VERSION_CODE = "last_version_code"
const val KEY_INSTALLED_VERSION_NAME = "installed_version_name"
const val KEY_STAGED_VERSION_NAME = "staged_version_name"
const val KEY_STAGED_AT = "staged_at"
}
}
@@ -127,8 +127,9 @@
<string name="checking_updates">Проверяю обновления…</string>
<string name="downloading_update">Скачиваю обновление…</string>
<string name="opening_installer">Открываю установщик Android…</string>
<string name="up_to_date">Установлена актуальная версия %1$s (%2$d).</string>
<string name="update_available">Доступна версия %1$s (%2$d).</string>
<string name="up_to_date">&#1059;&#1089;&#1090;&#1072;&#1085;&#1086;&#1074;&#1083;&#1077;&#1085;&#1072; &#1072;&#1082;&#1090;&#1091;&#1072;&#1083;&#1100;&#1085;&#1072;&#1103; &#1074;&#1077;&#1088;&#1089;&#1080;&#1103; %1$s.</string>
<string name="no_update_available">&#1042; Argus &#1085;&#1077;&#1090; &#1088;&#1077;&#1083;&#1080;&#1079;&#1072; &#1076;&#1083;&#1103; &#1101;&#1090;&#1086;&#1075;&#1086; &#1087;&#1088;&#1080;&#1083;&#1086;&#1078;&#1077;&#1085;&#1080;&#1103;.</string>
<string name="update_available">&#1044;&#1086;&#1089;&#1090;&#1091;&#1087;&#1085;&#1072; &#1074;&#1077;&#1088;&#1089;&#1080;&#1103; %1$s.</string>
<string name="update_check_failed">Не удалось проверить обновления.</string>
<string name="update_download_failed">Не удалось скачать обновление.</string>
<string name="allow_update_install">Разрешите установку из этого источника и повторите установку обновления.</string>
+210 -78
View File
@@ -1,99 +1,231 @@
param(
[string]$SshHost = $(if ($env:ARGUS_SSH_HOST) { $env:ARGUS_SSH_HOST } else { "192.168.0.185" }),
[string]$SshUser = $(if ($env:ARGUS_SSH_USER) { $env:ARGUS_SSH_USER } else { "sevenhill" }),
[string]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }),
[string]$Username = $env:ARGUS_USERNAME,
[string]$Password = $env:ARGUS_PASSWORD,
[string]$ArgusData = $(if ($env:ARGUS_DATA) { $env:ARGUS_DATA } else { "/srv/argus-data" }),
[string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk",
[string]$Slug = "keeper-android",
[string]$Name = "QKeep",
[string]$Summary = "Cloud notes app for QSfera with local storage and sync.",
[string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs Argus updates from inside the app.",
[string]$AndroidPackageName = "eu.qsfera.keeper",
[string]$Version = "0.1.21",
[int]$AndroidVersionCode = 22,
[string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs SHA-256 verified Argus updates.",
[string]$RepositoryUrl = "",
[string]$HomepageUrl = "",
[string]$Version = "0.1.22",
[string]$Channel = "stable",
[string]$Platform = "android",
[string]$PackageKind = "apk",
[string]$Notes = "Adds Keep-like list behavior settings for new and checked checklist items."
[string]$Notes = "Aligns the Android updater and publication flow with ARGUS_PUBLICATION.md."
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) {
throw "Set ARGUS_USERNAME and ARGUS_PASSWORD before publishing."
function ConvertTo-Base64Utf8([string]$Value) {
return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value))
}
function Assert-LastExitCode([string]$Action) {
if ($LASTEXITCODE -ne 0) {
throw "$Action failed with exit code $LASTEXITCODE."
}
}
$resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path
$baseUri = [Uri]($ArgusBaseUrl.TrimEnd("/"))
$slugEscaped = [Uri]::EscapeDataString($Slug)
$sshTarget = "$SshUser@$SshHost"
$remoteFile = "/tmp/$Slug-$Version.apk"
$manifestUrl = "$($ArgusBaseUrl.TrimEnd("/"))/api/apps/$Slug/manifest?platform=$Platform&channel=$Channel"
Add-Type -AssemblyName System.Net.Http
& scp "$resolvedApk" "${sshTarget}:$remoteFile"
Assert-LastExitCode "Copy APK to Argus host"
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.CookieContainer = [System.Net.CookieContainer]::new()
$client = [System.Net.Http.HttpClient]::new($handler)
$client.BaseAddress = $baseUri
$nameB64 = ConvertTo-Base64Utf8 $Name
$summaryB64 = ConvertTo-Base64Utf8 $Summary
$descriptionB64 = ConvertTo-Base64Utf8 $Description
$repositoryUrlB64 = ConvertTo-Base64Utf8 $RepositoryUrl
$homepageUrlB64 = ConvertTo-Base64Utf8 $HomepageUrl
$notesB64 = ConvertTo-Base64Utf8 $Notes
function New-JsonContent([object]$Value) {
$json = $Value | ConvertTo-Json -Depth 8
return [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, "application/json")
$remoteScript = @"
set -euo pipefail
export ARGUS_DATA="$ArgusData"
export SOURCE_FILE="$remoteFile"
export ARGUS_SLUG="$Slug"
export ARGUS_NAME="`$(printf '%s' '$nameB64' | base64 -d)"
export ARGUS_SUMMARY="`$(printf '%s' '$summaryB64' | base64 -d)"
export ARGUS_DESCRIPTION="`$(printf '%s' '$descriptionB64' | base64 -d)"
export ARGUS_REPOSITORY_URL="`$(printf '%s' '$repositoryUrlB64' | base64 -d)"
export ARGUS_HOMEPAGE_URL="`$(printf '%s' '$homepageUrlB64' | base64 -d)"
export ARGUS_IS_LISTED="1"
export ARGUS_VERSION="$Version"
export ARGUS_CHANNEL="$Channel"
export ARGUS_PLATFORM="$Platform"
export ARGUS_PACKAGE_KIND="$PackageKind"
export ARGUS_NOTES="`$(printf '%s' '$notesB64' | base64 -d)"
trap 'rm -f "$SOURCE_FILE"' EXIT
python3 - "`$SOURCE_FILE" <<'PY'
import hashlib
import mimetypes
import os
import re
import shutil
import sqlite3
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
source = Path(sys.argv[1]).resolve()
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
slug = os.environ["ARGUS_SLUG"].strip()
name = os.environ["ARGUS_NAME"].strip()
summary = os.environ["ARGUS_SUMMARY"].strip()
description = os.environ["ARGUS_DESCRIPTION"].strip()
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
version = os.environ["ARGUS_VERSION"].strip()
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
notes = os.environ.get("ARGUS_NOTES", "").strip() or None
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
if not source.is_file():
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
if not db_path.is_file():
raise SystemExit(f"Argus database does not exist: {db_path}")
for key, value in {
"ARGUS_NAME": name,
"ARGUS_SUMMARY": summary,
"ARGUS_DESCRIPTION": description,
"ARGUS_VERSION": version,
}.items():
if not value:
raise SystemExit(f"{key} is required.")
release_id = str(uuid.uuid4()).upper()
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
extension = source.suffix or ".bin"
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
stored_relative_path = f"{slug}/{stored_name}"
target_dir = packages_root / slug
target_path = target_dir / stored_name
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
if source.suffix.lower() == ".apk":
content_type = "application/vnd.android.package-archive"
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
if row is None:
app_id = str(uuid.uuid4()).upper()
conn.execute(
'''
INSERT INTO "Apps"
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
)
else:
app_id = row[0]
conn.execute(
'''
UPDATE "Apps"
SET "Name" = ?,
"Summary" = ?,
"Description" = ?,
"RepositoryUrl" = ?,
"HomepageUrl" = ?,
"IsListed" = ?,
"UpdatedAt" = ?
WHERE "Id" = ?
''',
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
)
duplicate = conn.execute(
'''
SELECT "Id"
FROM "Releases"
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
''',
(app_id, version, channel, platform),
).fetchone()
if duplicate is not None:
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
conn.execute(
'''
INSERT INTO "Releases"
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
"Sha256", "Notes", "PublishedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
release_id,
app_id,
version,
channel,
platform,
package_kind,
source.name,
stored_relative_path,
content_type,
size_bytes,
sha256,
notes,
now,
),
)
conn.commit()
except Exception:
conn.rollback()
try:
target_path.unlink()
except FileNotFoundError:
pass
raise
finally:
conn.close()
print(f"Published app={slug} version={version} channel={channel} platform={platform}")
print(f"StoredRelativePath={stored_relative_path}")
print(f"Size={size_bytes}")
print(f"Sha256={sha256}")
print(f"Manifest=$ArgusBaseUrl/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
PY
curl -sS "http://127.0.0.1:5105/api/apps/`$ARGUS_SLUG/manifest?platform=`$ARGUS_PLATFORM&channel=`$ARGUS_CHANNEL" >/dev/null
"@
$remoteScript | & ssh "$sshTarget" "bash -s"
Assert-LastExitCode "Publish release on Argus host"
$manifest = Invoke-RestMethod -Uri $manifestUrl -Headers @{ Accept = "application/json" }
if ($manifest.release.version -ne $Version) {
throw "Argus manifest verification failed: expected version $Version, got $($manifest.release.version)."
}
function Read-ResponseBody($Response) {
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Assert-Success($Response, [string]$Action) {
if (-not $Response.IsSuccessStatusCode) {
$body = Read-ResponseBody $Response
throw "$Action failed: HTTP $([int]$Response.StatusCode) $body"
}
}
try {
$loginPayload = @{
username = $Username
password = $Password
}
$loginResponse = $client.PostAsync("/api/admin/session/login", (New-JsonContent $loginPayload)).GetAwaiter().GetResult()
Assert-Success $loginResponse "Argus login"
$metadataPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = $null
homepageUrl = $null
isListed = $true
}
$metadataResponse = $client.PutAsync("/api/admin/apps/$slugEscaped", (New-JsonContent $metadataPayload)).GetAwaiter().GetResult()
Assert-Success $metadataResponse "Argus app metadata update"
$form = [System.Net.Http.MultipartFormDataContent]::new()
$form.Add([System.Net.Http.StringContent]::new($Version), "Version")
$form.Add([System.Net.Http.StringContent]::new($Channel), "Channel")
$form.Add([System.Net.Http.StringContent]::new($Platform), "Platform")
$form.Add([System.Net.Http.StringContent]::new($PackageKind), "PackageKind")
$form.Add([System.Net.Http.StringContent]::new($AndroidVersionCode.ToString()), "AndroidVersionCode")
$form.Add([System.Net.Http.StringContent]::new($Notes), "Notes")
$fileStream = [IO.File]::OpenRead($resolvedApk)
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("application/vnd.android.package-archive")
$form.Add($fileContent, "Package", [IO.Path]::GetFileName($resolvedApk))
$releaseResponse = $client.PostAsync("/api/admin/apps/$slugEscaped/releases", $form).GetAwaiter().GetResult()
Assert-Success $releaseResponse "Argus release upload"
$releaseBody = Read-ResponseBody $releaseResponse
Write-Output $releaseBody
} finally {
if ($fileStream) {
$fileStream.Dispose()
}
if ($form) {
$form.Dispose()
}
$client.Dispose()
$handler.Dispose()
}
Write-Output "Published $Slug $Version to $manifestUrl"
+26 -10
View File
@@ -23,11 +23,19 @@ Date: 2026-06-08.
- Android release builds now fail fast when release signing variables are missing, so unsigned release artifacts cannot be published by accident.
- Android version was raised to `1.3.12` with `versionCode` `40`.
- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and Argus upload.
- Android version was raised to `1.3.13` with `versionCode` `41` for the next Argus publication.
- Android version was raised to `1.3.14` with `versionCode` `42` for the updated SSH-only Argus publication flow.
- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and SSH-only Argus publication through the `/srv/argus-data/argus.db` and `/srv/argus-data/Packages` workflow required by `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; HTTP `/api/admin/*` publication is not used.
- Android Argus update and publication were realigned with `G:/Repos/Marketplace/ARGUS_PUBLICATION.md` on 2026-06-13: `Android/scripts/publish-release.ps1` now generates and runs the SSH/SQLite/Data/Packages server-side publication block, and `AppUpdateManager` uses only the public read-only manifest contract for update discovery.
- Android now has `Android/scripts/verify-release-signing-material.ps1` to verify local release signing properties, keystore readability, release certificate SHA-256, and optionally the latest published Argus APK certificate without printing signing secrets.
- Android Argus staging now enforces release git provenance by default: the work tree must be clean, the branch must have an upstream, `git fetch` must succeed, and `HEAD` must match `@{u}` before a stable APK is prepared for upload. The generated `argus-publication.json` records the verified repository root, branch, upstream, commit, and timestamp; current monorepo publication can verify the pushed root `QSfera.git` branch by running from `Android/` with `-GitProvenanceRoot ..`.
- Android `1.3.9`/`37` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `53ecc24e-106b-43a5-a39e-ffacd72a43f8`, package size `12226572`, and SHA-256 `cbd2c25328e395ea4da4e0f582feac39dec18d2d47b3e105d344904d8a24f017`.
- Android `1.3.10`/`38` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `8465ba6d-a008-45f5-9c70-e6cd2c94317a`, package size `12228464`, and SHA-256 `ce33a73ce122de6c9c797e7cb552e5493b628c60ddc98fda6a9e230e3c1ed7fe`.
- Android `1.3.11`/`39` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `3dbac5ad-b19c-46e6-b24f-8f22e1c466dd`, package size `12234884`, and SHA-256 `c9eae41bbaae421e1a69721e891d8431ce599684e0f655b71693a422aa83720a`.
- Android `1.3.12`/`40` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `615b58c5-b5f7-4a2a-b6de-03f821eb43a4`, package size `12237552`, and SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`.
- Android `1.3.13`/`41` was published to Argus as `qsfera-mobile` on 2026-06-12. Argus upload returned release id `39072ada-7557-48f3-ab42-ad68703d00b0`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`.
- Android `1.3.14`/`42` was published to Argus as `qsfera-mobile` on 2026-06-13 through the SSH/SQLite/Data/Packages workflow from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`. The server-side publication block returned release id `4394333f-a84a-4a3b-9bc7-6a48d054d4fe`, stored path `qsfera-mobile/20260613170521-1.3.14-4394333FA84A4A3B9BC76A48D054D4FE.apk`, package size `12262908`, and SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`.
- `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk` passed on 2026-06-12: local `Android/argus-signing.local.properties` and `Android/signing/argus-upload.jks` are complete/readable, alias `argus-upload` exists, and both the keystore certificate and the latest published Argus APK report certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
- Android auto-upload source paths are normalized and new/first source folders start from timestamp `0`, so existing media is eligible for initial backup.
- Android auto-upload validates persisted read permission and `DocumentFile.canRead()` before scanning SAF folders.
- Android immediate auto-upload scheduling no longer blocks the caller while waiting for WorkManager state.
@@ -37,10 +45,16 @@ 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 photo/video backup status now shows per-source scan state, missing SAF folder access, and selected-account low-storage warnings in the settings summary. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` passed on 2026-06-10.
- Android photo/video backup status now also observes automatic-upload transfers live and reports uploading, queued, failed, and paused-by-network/roaming/charging states in the settings summary. `:qsferaApp:testOriginalDebugUnitTest --tests "eu.qsfera.android.presentation.settings.automaticuploads.AutomaticUploadStatusFormatterTest"` and `:qsferaApp:compileOriginalDebugKotlin` 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.
- Android now keeps Graph-style `internal` and `blocksDownload` public links as server-managed custom-access states in the OCS public-link editor: list rows show `Custom access`, the edit dialog explains that the link is managed by the server, and saving is disabled to avoid downgrading organization-only or secure-view protection to a standard OCS link.
- Sharing controls were audited against the Google Drive and Yandex Disk benchmarks in `Server/docs/sharing-controls-production-audit.md`, and `scripts/verify-sharing-controls-coverage.ps1` now checks local Android UI, Graph link types, server link handling, and acceptance-feature coverage for password, expiration, access roles, internal links, secure-view/no-download behavior, and role-based resharing.
- 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.
- Added `scripts/validate-server-production-env.ps1` with the same `qsfera_full` production `.env` checks for the local Windows/PowerShell workflow; it validates required domains, release image pinning, disabled demo/insecure modes, required secrets, optional Keycloak/S3 blocks, `COMPOSE_PATH_SEPARATOR`, selected `COMPOSE_FILE` fragments, and backup-friendly config/data path warnings without printing secret values.
- `scripts/validate-server-production-env.ps1` was verified on 2026-06-12: it rejects `Server/devtools/deployments/qsfera_full/env.production.example` with placeholder values, accepts a temporary filled production-shaped env file with `0 warning(s)`, and rejects a temporary env file where `COMPOSE_FILE` omits the required `qsfera.yml` fragment.
- Server now has a Raspberry Pi 5 Docker path matching the Ikar/Argus deployment style: `Server/docker-compose.rpi5.yml`, `Server/.env.example`, and `Server/docs/raspberry-pi-5-docker.md`.
- Server Docker builds now accept explicit production metadata for `QSFERA_VERSION`, `QSFERA_EDITION`, and `QSFERA_BUILD_DATE`, and the Raspberry Pi 5 compose path defaults to pinned image `qsfera-cloud-server:7.0.0-rpi5-stable`.
- Windows/Desktop update channel defaults now match the build channel: stable builds default to `stable`, beta builds default to `beta`.
@@ -49,8 +63,12 @@ Date: 2026-06-08.
- Windows local autostart was repaired on 2026-06-10 with `scripts/repair-windows-autostart.ps1 -Launch`: the HKCU Run key `QSfera` points to the Craft runtime `bin\qsfera.exe`, required Qt6 runtime DLLs are present there, and `qsfera.exe` started from that path.
- Windows tray "Show" now brings the settings window to the foreground after restoring it to an available screen. The same `Application::showSettings()` path is used by tray menu actions and `qsfera.exe --show`, and Win32 window enumeration on 2026-06-10 reported `Visible=True`, `Foreground=True`, title `КуСфера`.
- Windows Desktop now has repeatable local packaging and feed-publication scripts. `scripts/package-windows-desktop.ps1` produced a smoke-tested `4.0.0` x64 package with Qt/MSVC runtime files; `scripts/publish-windows-desktop.ps1` generated a valid local `stable.xml` feed for that MSI and refuses remote publication unless the MSI is Authenticode-signed.
- Added `scripts/verify-production-state.ps1` as a repeatable live production endpoint gate for the public server status, Android Argus manifest/download, and Desktop update feed.
- Windows Desktop packaging now fails before ZIP/MSI creation if the staged app is missing critical Qt6/MSVC/OpenSSL DLLs, the Qt platform plugin, QSfera VFS plugins, QML import manifests, or `qt.conf` runtime path entries; it also smoke-starts the staged `qsfera.exe --version` from the staged working directory.
- Added `scripts/verify-production-state.ps1` as a repeatable live production endpoint gate for the public server status, Android Argus manifest/download, and Desktop update feed; the Android gate now pins the stable release id, original APK filename, content type, package size, download path, and SHA-256.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-10: server `edition=stable`/`productversion=7.0.0`, Android `1.3.12`/`40`, downloaded APK size `12237552`, APK SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`, and valid Desktop no-update feed.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-12: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android `1.3.13`/`41`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-13 against the current public Argus manifest contract: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android version `1.3.13`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed. The live manifest currently omits `androidPackageName` and `androidVersionCode`; the verifier treats both as optional unless strict switches are set.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-13 after publishing Android `1.3.14`: server `edition=stable`/`productversion=7.0.0`, Android release id `4394333f-a84a-4a3b-9bc7-6a48d054d4fe`, Android version `1.3.14`, APK filename `qsfera-mobile-42.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12262908`, APK SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`, and valid Desktop no-update feed.
- Added `Server/scripts/verify-rpi-production.sh`, declared a loopback-only Pi metrics port in `Server/docker-compose.rpi5.yml`, and expanded the Raspberry Pi Docker runbook with local/public status checks, container/image checks, Prometheus metrics verification, backup/restore drill steps, and Docker data-root/retention planning.
- Added systemd monitoring units for the Pi: `Server/systemd/qsfera-rpi-production-check.service`, `Server/systemd/qsfera-rpi-production-check.timer`, `Server/systemd/qsfera-rpi-production-check-alert@.service`, `Server/systemd/rpi-production-check.env.example`, plus installer `Server/scripts/install-rpi-production-monitoring.sh` and alert helper `Server/scripts/alert-rpi-production-check-failure.sh`.
- Deployed the Pi metrics/checker update on 2026-06-09. Before upload, `/mnt/data/qsfera/cloud-server/src/docker-compose.rpi5.yml` was backed up to `/mnt/data/qsfera/backups/docker-compose.rpi5.yml-20260609-181614.bak`; `docker compose -f docker-compose.rpi5.yml up -d` recreated and started `qsfera-cloud-server`; `docker compose ps` reported loopback ports `127.0.0.1:9200->9200/tcp` and `127.0.0.1:9205->9205/tcp`.
@@ -71,24 +89,22 @@ Date: 2026-06-08.
## Android Argus Publication Check
- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `615b58c5-b5f7-4a2a-b6de-03f821eb43a4`, version `1.3.12`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `40`, package size `12237552`, and SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`.
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12237552` byte APK with the same SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`.
- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `4394333f-a84a-4a3b-9bc7-6a48d054d4fe`, version `1.3.14`, channel `stable`, platform `android`, package kind `apk`, original file name `qsfera-mobile-42.apk`, content type `application/vnd.android.package-archive`, package size `12262908`, and SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`. It does not currently include legacy Android-specific root `androidPackageName` or release `androidVersionCode` fields.
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12262908` byte APK with the same SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`.
- The previously published `1.3.5`/`33` APK matched Argus SHA-256 `b7d5bdcb49527c00983a15849252fa42189dee4287754803c46cf2f32e2130f3`, but `apksigner` reported `CN=Android Debug` with certificate SHA-256 `b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941`.
- The new `1.3.12`/`40` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
- `AppUpdateManager` validates downloaded APK package name, greater `versionCode`, Argus `androidVersionCode`, and signer digest against the installed app signature. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK.
- The current production-signed `1.3.14`/`42` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
- `AppUpdateManager` follows the public read-only Argus manifest contract from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: HTTP `404` is treated as no update, `release.version` is compared with the installed version, downloads use `ARGUS_BASE_URL + release.downloadPath`, `release.packageSizeBytes` and `release.sha256` are verified before install, and the downloaded APK package name/version/signature are checked against the installed app. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK.
- The rollout policy for debug-signed `1.3.5` devices is documented in `Android/docs/argus-rollout-policy.md`: the app does not bypass signer mismatch; users must back up unsynced local files, uninstall the debug-signed build, then install the current stable APK from Argus. The Android app now shows a reinstall-required dialog for this certificate-mismatch path.
## Remaining P0
- Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from the production-signed `1.3.12` cannot be signed with the same certificate.
- Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from the production-signed `1.3.14` cannot be signed with the same certificate.
- Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
- Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values.
- Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified.
- Run `./validate-production-env.sh .env` or `.\scripts\validate-server-production-env.ps1 -EnvPath ...` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified.
- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference. The current local MSI is intentionally not published as a production update because `Get-AuthenticodeSignature` reports `NotSigned` and `Desktop/scripts/validate-production-release.ps1 -RequireSigning` fails until signing is configured.
- Publish the first signed Windows MSI with `scripts/publish-windows-desktop.ps1 -RemoteHost ...` and update `https://qsfera.kusoft.xyz/desktop/updates/stable.xml` from the current no-update XML to a concrete `<version>`, `<versionstring>`, and HTTPS `.msi` `<downloadurl>`.
## Remaining P1
- Extend Android backup status UX beyond the new overall settings summary with per-item status, queued/paused/permanent-failure states, and low-storage messaging.
- Configure `QSFERA_ALERT_WEBHOOK_URL` in `/etc/qsfera/rpi-production-check.env` if off-host alert delivery is required; the installed failure service currently logs through `systemd-cat` and journal without an external endpoint.
- Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance.
@@ -0,0 +1,33 @@
# QSfera Sharing Controls Production Audit
Date: 2026-06-10.
## External Benchmarks
- Google Drive documents file-sharing roles, per-link/general access, download control for viewers/commenters, and folder permission inheritance on its Android sharing help page: <https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DAndroid&hl=en>.
- Yandex Disk documents mobile link security settings for view-only access, disabling downloads, link password, expiration date, and organization-only links on its Android sharing help page: <https://yandex.com/support/yandex-360/customers/disk/app/android/en/actions/share>.
## Verified QSfera Coverage
| Benchmark control | QSfera evidence | Status |
| --- | --- | --- |
| Public-link password | Android exposes `shareViaLinkPasswordSwitch`; Graph `CreateLink` stores `Password`; acceptance tests create and update password-protected link shares and verify old passwords fail with HTTP `401`. | Covered for Android OCS links and Graph links. |
| Public-link expiration | Android exposes `shareViaLinkExpirationSwitch`; Graph `CreateLink` reads `expirationDateTime`; acceptance tests create, update, and remove expiration dates. | Covered for Android OCS links and Graph links. |
| Link access role | Android exposes read-only, read/write, and upload-only radio options; Graph link types include `view`, `edit`, `upload`, and `createOnly`; acceptance tests cover those roles for files, folders, and project-space links. | Covered, with different Android OCS labels and Graph role names. |
| Disable downloads / secure view | Graph link types include `blocksDownload`; acceptance tests also verify the `Secure Viewer` role cannot download files. Android public-link rows display non-standard link permissions as `Custom access`, and the Android edit dialog treats those links as server-managed so they cannot be downgraded through the OCS dialog. | Server/API coverage exists; Android keeps `blocksDownload` links server-managed until a Graph-link editor is added. |
| Organization-only access | Graph link types include `internal`; frontend config documents default link permission `0` as an internal/member-only link; server rejects redundant passwords on internal links; acceptance tests create and update internal links. Android public-link rows display these non-standard states as `Custom access`, and the Android edit dialog treats them as server-managed. | Server/API coverage exists; Android keeps `internal` links server-managed until a Graph-link editor is added. |
| Role inheritance / resharing | Space sharing acceptance tests verify managers can reshare spaces, editors/viewers cannot, and project-space public-link creation requires manager rights. | Covered by server acceptance scenarios. |
## Repeatable Local Check
Run from the repository root:
```powershell
.\scripts\verify-sharing-controls-coverage.ps1
```
The script is a static coverage gate over Android UI files, Graph link type code, server link creation code, and acceptance feature files. It does not replace a full server acceptance run.
## Product Decision
The Android public-link dialog exposes the OCS link controls already present in the client: password, expiration, read-only, read/write, and upload-only. Dedicated Graph `internal` and `blocksDownload` creation/editing controls remain server-managed on Android until a Graph-link editor is added. The Android list shows those links as `Custom access`, and the Android edit dialog disables saving for them so a user cannot accidentally replace organization-only or secure-view protection with a standard OCS read-only link.
+60
View File
@@ -63,6 +63,64 @@ function Copy-DirectoryIfPresent([string]$Source, [string]$Destination) {
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
function Assert-StageFile([string]$StageDirectory, [string]$RelativePath) {
$path = Join-Path $StageDirectory $RelativePath
if (-not (Test-Path -LiteralPath $path)) {
Fail "Packaged runtime file is missing: $path"
}
if ((Get-Item -LiteralPath $path).Length -le 0) {
Fail "Packaged runtime file is empty: $path"
}
}
function Assert-WindowsRuntimeStage([string]$StageDirectory) {
$criticalFiles = @(
"qsfera.exe",
"qt.conf",
"Qt6Core.dll",
"Qt6Gui.dll",
"Qt6Widgets.dll",
"Qt6Network.dll",
"Qt6Qml.dll",
"Qt6Quick.dll",
"Qt6QuickControls2.dll",
"qt6keychain.dll",
"kdsingleapplication-qt6.dll",
"libcrypto-3-x64.dll",
"libssl-3-x64.dll",
"plugins\platforms\qwindows.dll",
"plugins\tls\qschannelbackend.dll",
"plugins\QSfera_vfs_cfapi.dll",
"plugins\QSfera_vfs_off.dll",
"qml\eu\QSfera\gui\qmldir",
"qml\QtQuick\Controls\qmldir"
)
foreach ($file in $criticalFiles) {
Assert-StageFile -StageDirectory $StageDirectory -RelativePath $file
}
$qtConf = Get-Content -LiteralPath (Join-Path $StageDirectory "qt.conf") -Raw -Encoding ASCII
foreach ($entry in @("Plugins=plugins", "Qml2Imports=qml", "Translations=translations")) {
if (-not $qtConf.Contains($entry)) {
Fail "qt.conf does not contain required entry '$entry'."
}
}
Push-Location $StageDirectory
try {
$versionOutput = & (Join-Path $StageDirectory "qsfera.exe") --version 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "Packaged qsfera.exe failed to start from the staging directory. Output: $($versionOutput | Out-String)"
}
} finally {
Pop-Location
}
Write-Output "OK: packaged Windows runtime contains required Qt/MSVC/OpenSSL files and qsfera.exe --version starts from the staging directory."
}
function ConvertTo-WixIdentifier([string]$Prefix, [string]$Value) {
$normalized = ($Value -replace '[^A-Za-z0-9_]', '_')
if ($normalized.Length -gt 60) {
@@ -312,6 +370,8 @@ Qml2Imports=qml
Translations=translations
"@ | Set-Content -LiteralPath (Join-Path $StageDirectory "qt.conf") -Encoding ASCII
Assert-WindowsRuntimeStage -StageDirectory $StageDirectory
Remove-PathWithRetry $zipPath
Compress-ArchiveWithRetry -SourcePath (Join-Path $StageDirectory "*") -DestinationPath $zipPath
+190
View File
@@ -0,0 +1,190 @@
param(
[string]$EnvPath = (Join-Path $PSScriptRoot "..\Server\devtools\deployments\qsfera_full\.env"),
[switch]$AllowWarnings
)
$ErrorActionPreference = "Stop"
$errors = New-Object System.Collections.Generic.List[string]
$warnings = New-Object System.Collections.Generic.List[string]
function Add-Error([string]$Message) {
[void]$script:errors.Add($Message)
Write-Error $Message -ErrorAction Continue
}
function Add-WarningMessage([string]$Message) {
[void]$script:warnings.Add($Message)
Write-Warning $Message
}
function Read-EnvFile([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) {
throw "Env file not found: $Path"
}
$result = @{}
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith("#")) {
continue
}
$separator = $trimmed.IndexOf("=")
if ($separator -le 0) {
continue
}
$key = $trimmed.Substring(0, $separator).Trim()
$value = $trimmed.Substring($separator + 1).Trim()
$value = $value.Trim('"').Trim("'")
$result[$key] = $value
}
return $result
}
function Get-EnvValue([hashtable]$Values, [string]$Key) {
if ($Values.ContainsKey($Key)) {
return [string]$Values[$Key]
}
return ""
}
function Test-Placeholder([string]$Value) {
if ([string]::IsNullOrWhiteSpace($Value)) {
return $true
}
if ($Value.StartsWith("<") -and $Value.EndsWith(">")) {
return $true
}
return $Value -in @("change-me", "changeme", "password", "secret") -or $Value.Contains("example.com")
}
function Test-Enabled([hashtable]$Values, [string]$Key) {
return -not [string]::IsNullOrWhiteSpace((Get-EnvValue $Values $Key))
}
function Require-Value([hashtable]$Values, [string]$Key, [string]$Label = $Key) {
$value = Get-EnvValue $Values $Key
if (Test-Placeholder $value) {
Add-Error "$Label must be set to a production value."
}
}
function Require-Secret([hashtable]$Values, [string]$Key) {
$value = Get-EnvValue $Values $Key
if (Test-Placeholder $value) {
Add-Error "$Key must be set."
return
}
if ($value -in @("admin", "demo", "keycloak", "qsfera", "qsfera-secret-key")) {
Add-Error "$Key uses a known demo/default value."
return
}
if ($value.Length -lt 16) {
Add-Error "$Key must be at least 16 characters."
}
}
function Require-ExactValue([hashtable]$Values, [string]$Key, [string]$ExpectedValue) {
$value = Get-EnvValue $Values $Key
if ($value -ne $ExpectedValue) {
Add-Error "$Key must be $ExpectedValue."
}
}
function Test-ComposeFileIncludes([string]$ComposeFile, [string]$EnvKey, [string]$Fragment) {
if ([string]::IsNullOrWhiteSpace($ComposeFile)) {
return $false
}
return $ComposeFile.Contains($Fragment) -or $ComposeFile.Contains('${' + $EnvKey + ':-}')
}
$resolvedEnvPath = (Resolve-Path -LiteralPath $EnvPath -ErrorAction Stop).Path
$values = Read-EnvFile $resolvedEnvPath
Require-Value $values "OC_DOMAIN"
Require-Value $values "TRAEFIK_ACME_MAIL"
Require-Secret $values "ADMIN_PASSWORD"
if ((Get-EnvValue $values "INSECURE") -in @("true", "TRUE", "1", "yes", "YES")) {
Add-Error "INSECURE must be false for production."
}
if ((Get-EnvValue $values "DEMO_USERS") -in @("true", "TRUE", "1", "yes", "YES")) {
Add-Error "DEMO_USERS must be false for production."
}
switch (Get-EnvValue $values "OC_DOCKER_IMAGE") {
"qsfera/qsfera" { }
"" { Add-WarningMessage "OC_DOCKER_IMAGE is empty; compose default must be verified before rollout." }
default { Add-Error "OC_DOCKER_IMAGE must be qsfera/qsfera for production." }
}
$dockerTag = Get-EnvValue $values "OC_DOCKER_TAG"
if ([string]::IsNullOrWhiteSpace($dockerTag) -or $dockerTag -eq "latest" -or ($dockerTag.StartsWith("<") -and $dockerTag.EndsWith(">"))) {
Add-Error "OC_DOCKER_TAG must pin a concrete production release tag."
}
Require-ExactValue $values "COMPOSE_PATH_SEPARATOR" ":"
$composeFile = Get-EnvValue $values "COMPOSE_FILE"
if ([string]::IsNullOrWhiteSpace($composeFile) -or -not $composeFile.Contains("docker-compose.yml")) {
Add-Error "COMPOSE_FILE must include docker-compose.yml."
}
if (-not (Test-Enabled $values "QSFERA")) {
Add-Error "QSFERA=:qsfera.yml must be enabled."
} else {
Require-ExactValue $values "QSFERA" ":qsfera.yml"
if (-not (Test-ComposeFileIncludes $composeFile "QSFERA" "qsfera.yml")) {
Add-Error "COMPOSE_FILE must include qsfera.yml or the `${QSFERA:-} expansion."
}
}
if (Test-Enabled $values "DECOMPOSEDS3") {
Require-ExactValue $values "DECOMPOSEDS3" ":decomposeds3.yml"
if (-not (Test-ComposeFileIncludes $composeFile "DECOMPOSEDS3" "decomposeds3.yml")) {
Add-Error "COMPOSE_FILE must include decomposeds3.yml or the `${DECOMPOSEDS3:-} expansion when DECOMPOSEDS3 is enabled."
}
Require-Value $values "DECOMPOSEDS3_ENDPOINT"
Require-Value $values "DECOMPOSEDS3_REGION"
Require-Secret $values "DECOMPOSEDS3_ACCESS_KEY"
Require-Secret $values "DECOMPOSEDS3_SECRET_KEY"
Require-Value $values "DECOMPOSEDS3_BUCKET"
}
if (Test-Enabled $values "DECOMPOSEDS3_MINIO") {
Add-Error "DECOMPOSEDS3_MINIO is for test/lab installs and must not be enabled for public production."
if (-not (Test-ComposeFileIncludes $composeFile "DECOMPOSEDS3_MINIO" "minio.yml")) {
Add-Error "COMPOSE_FILE must include minio.yml or the `${DECOMPOSEDS3_MINIO:-} expansion when DECOMPOSEDS3_MINIO is enabled."
}
}
if (Test-Enabled $values "KEYCLOAK") {
Require-ExactValue $values "KEYCLOAK" ":keycloak.yml"
if (-not (Test-ComposeFileIncludes $composeFile "KEYCLOAK" "keycloak.yml")) {
Add-Error "COMPOSE_FILE must include keycloak.yml or the `${KEYCLOAK:-} expansion when KEYCLOAK is enabled."
}
Require-Value $values "KEYCLOAK_DOMAIN"
Require-Value $values "KEYCLOAK_REALM"
Require-Secret $values "KEYCLOAK_POSTGRES_PASSWORD"
Require-Value $values "KEYCLOAK_ADMIN_USER"
Require-Secret $values "KEYCLOAK_ADMIN_PASSWORD"
}
if ([string]::IsNullOrWhiteSpace((Get-EnvValue $values "OC_CONFIG_DIR")) -or [string]::IsNullOrWhiteSpace((Get-EnvValue $values "OC_DATA_DIR"))) {
Add-WarningMessage "OC_CONFIG_DIR and OC_DATA_DIR are not both set; docker volumes are harder to back up and restore."
}
if ($errors.Count -gt 0) {
throw "Production env validation failed: $($errors.Count) error(s), $($warnings.Count) warning(s)."
}
if (-not $AllowWarnings -and $warnings.Count -gt 0) {
throw "Production env validation passed with $($warnings.Count) warning(s). Re-run with -AllowWarnings if this is expected."
}
Write-Output "Production env validation passed: $($warnings.Count) warning(s)."
+31 -7
View File
@@ -6,12 +6,18 @@ param(
[string]$AndroidManifestUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable",
[string]$ExpectedAndroidSlug = "qsfera-mobile",
[string]$ExpectedAndroidPackageName = "eu.qsfera.android",
[string]$ExpectedAndroidVersion = "1.3.12",
[int]$ExpectedAndroidVersionCode = 40,
[switch]$RequireAndroidPackageName,
[string]$ExpectedAndroidVersion = "1.3.14",
[int]$ExpectedAndroidVersionCode = 42,
[switch]$RequireAndroidVersionCode,
[string]$ExpectedAndroidChannel = "stable",
[string]$ExpectedAndroidPlatform = "android",
[string]$ExpectedAndroidPackageKind = "apk",
[string]$ExpectedAndroidSha256 = "279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded",
[string]$ExpectedAndroidReleaseId = "4394333f-a84a-4a3b-9bc7-6a48d054d4fe",
[string]$ExpectedAndroidOriginalFileName = "qsfera-mobile-42.apk",
[string]$ExpectedAndroidContentType = "application/vnd.android.package-archive",
[int64]$ExpectedAndroidPackageSizeBytes = 12262908,
[string]$ExpectedAndroidSha256 = "8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18",
[string]$AndroidDownloadUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable",
[switch]$VerifyAndroidDownload,
@@ -104,7 +110,14 @@ Write-Host "Checking QSfera Android Argus manifest..."
$manifestResponse = Invoke-CheckedWebRequest "Android Argus manifest" $AndroidManifestUrl
$manifest = Convert-JsonContent "Android Argus manifest" $manifestResponse.Content
Assert-Equal "Android app slug" (Get-RequiredProperty $manifest "slug" "Android manifest") $ExpectedAndroidSlug
Assert-Equal "Android package name" (Get-RequiredProperty $manifest "androidPackageName" "Android manifest") $ExpectedAndroidPackageName
$androidPackageNameProperty = $manifest.PSObject.Properties["androidPackageName"]
if ($null -ne $androidPackageNameProperty) {
Assert-Equal "Android package name" $androidPackageNameProperty.Value $ExpectedAndroidPackageName
} elseif ($RequireAndroidPackageName) {
Fail "Android manifest is missing 'androidPackageName'."
} else {
Write-Host "OK: Android package name is absent; public Argus contract does not require it"
}
$release = Get-RequiredProperty $manifest "release" "Android manifest"
if ($null -eq $release) {
@@ -112,10 +125,22 @@ if ($null -eq $release) {
}
Assert-Equal "Android release version" (Get-RequiredProperty $release "version" "Android release") $ExpectedAndroidVersion
Assert-Equal "Android versionCode" (Get-RequiredProperty $release "androidVersionCode" "Android release") $ExpectedAndroidVersionCode
$androidVersionCodeProperty = $release.PSObject.Properties["androidVersionCode"]
if ($null -ne $androidVersionCodeProperty) {
Assert-Equal "Android versionCode" $androidVersionCodeProperty.Value $ExpectedAndroidVersionCode
} elseif ($RequireAndroidVersionCode) {
Fail "Android release is missing 'androidVersionCode'."
} else {
Write-Host "OK: Android versionCode is absent; public Argus contract does not require it"
}
Assert-Equal "Android release channel" (Get-RequiredProperty $release "channel" "Android release") $ExpectedAndroidChannel
Assert-Equal "Android release platform" (Get-RequiredProperty $release "platform" "Android release") $ExpectedAndroidPlatform
Assert-Equal "Android package kind" (Get-RequiredProperty $release "packageKind" "Android release") $ExpectedAndroidPackageKind
Assert-Equal "Android release id" (Get-RequiredProperty $release "id" "Android release") $ExpectedAndroidReleaseId
Assert-Equal "Android original file name" (Get-RequiredProperty $release "originalFileName" "Android release") $ExpectedAndroidOriginalFileName
Assert-Equal "Android content type" (Get-RequiredProperty $release "contentType" "Android release") $ExpectedAndroidContentType
Assert-Equal "Android package size" (Get-RequiredProperty $release "packageSizeBytes" "Android release") $ExpectedAndroidPackageSizeBytes
Assert-Equal "Android release download path" (Get-RequiredProperty $release "downloadPath" "Android release") "/api/apps/$ExpectedAndroidSlug/releases/$ExpectedAndroidReleaseId/download"
$releaseSha256 = Get-RequiredProperty $release "sha256" "Android release"
Assert-True "Android SHA-256 shape" (Test-Sha256String $releaseSha256)
Assert-Equal "Android SHA-256" $releaseSha256 $ExpectedAndroidSha256
@@ -132,8 +157,7 @@ if ($VerifyAndroidDownload) {
}
$downloadSize = (Get-Item -LiteralPath $tempFile).Length
$expectedSize = Get-RequiredProperty $release "packageSizeBytes" "Android release"
Assert-Equal "Android APK size" $downloadSize $expectedSize
Assert-Equal "Android APK size" $downloadSize $ExpectedAndroidPackageSizeBytes
$downloadSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $tempFile).Hash.ToLowerInvariant()
Assert-Equal "Android APK download SHA-256" $downloadSha256 $ExpectedAndroidSha256
@@ -0,0 +1,88 @@
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
)
$ErrorActionPreference = "Stop"
function Fail($Message) {
Write-Error $Message
exit 1
}
function Assert-FileContains($Name, $RelativePath, $Pattern) {
$path = Join-Path $RepoRoot $RelativePath
if (-not (Test-Path -LiteralPath $path)) {
Fail "$Name source file is missing: $RelativePath"
}
$content = Get-Content -LiteralPath $path -Raw
if ($content -notmatch $Pattern) {
Fail "$Name was not found in $RelativePath. Pattern: $Pattern"
}
Write-Host "OK: $Name"
}
Write-Host "Checking QSfera sharing-controls coverage..."
Assert-FileContains "Android public-link dialog exposes password control" `
"Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt" `
"shareViaLinkPasswordSwitch"
Assert-FileContains "Android public-link dialog exposes expiration control" `
"Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt" `
"shareViaLinkExpirationSwitch"
Assert-FileContains "Android public-link dialog exposes upload-only mode" `
"Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt" `
"shareViaLinkEditPermissionUploadFiles"
Assert-FileContains "Android public-link rows show safety status" `
"Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/SharePublicLinkListAdapter.kt" `
"PublicShareStatusFormatter\.statusFor"
Assert-FileContains "Android public-link dialog protects server-managed custom links" `
"Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/sharing/shares/PublicShareDialogFragment.kt" `
"isServerManagedPublicLink"
Assert-FileContains "Android public-link dialog explains managed access" `
"Android/qsferaApp/src/main/res/layout/share_public_dialog.xml" `
"shareViaLinkManagedAccessMessage"
Assert-FileContains "Graph API declares internal link type" `
"Server/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sharing_link_type.go" `
"INTERNAL SharingLinkType = `"internal`""
Assert-FileContains "Graph API declares secure-view link type" `
"Server/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sharing_link_type.go" `
"BLOCKS_DOWNLOAD SharingLinkType = `"blocksDownload`""
Assert-FileContains "Graph API declares file-drop link type" `
"Server/vendor/github.com/opencloud-eu/libre-graph-api-go/model_sharing_link_type.go" `
"CREATE_ONLY SharingLinkType = `"createOnly`""
Assert-FileContains "Server maps internal link to no public-share permissions" `
"Server/services/graph/pkg/linktype/linktype.go" `
"func NewInternalLinkPermissionSet\(\) \*LinkType[\s\S]*ResourcePermissions\{\}"
Assert-FileContains "Server rejects passwords on internal links" `
"Server/services/graph/pkg/service/v0/api_driveitem_permissions_links.go" `
"password is redundant for the internal link"
Assert-FileContains "Server stores link expiration during creation" `
"Server/services/graph/pkg/service/v0/api_driveitem_permissions_links.go" `
"GetExpirationDateTimeOk"
Assert-FileContains "Acceptance tests create link shares with password and expiration" `
"Server/tests/acceptance/features/apiSharingNgLinkSharePermission/createLinkShare.feature" `
"expirationDateTime[\s\S]*hasPassword"
Assert-FileContains "Acceptance tests update link-share expiration" `
"Server/tests/acceptance/features/apiSharingNgLinkSharePermission/updateLinkShare.feature" `
"update expiration date of a file's link share"
Assert-FileContains "Acceptance tests update link-share password and reject old password" `
"Server/tests/acceptance/features/apiSharingNgLinkSharePermission/updateLinkShare.feature" `
"public download of file[\s\S]*should fail with HTTP status code `"401`""
Assert-FileContains "Acceptance tests update public links to internal links" `
"Server/tests/acceptance/features/apiSharingNgLinkSharePermission/updateLinkShare.feature" `
"permissionsRole \| internal"
Assert-FileContains "Acceptance tests include secure viewer no-download behavior" `
"Server/tests/acceptance/features/apiSharingNg1/enableDisablePermissionsRole.feature" `
"Secure Viewer[\s\S]*should not be able to download"
Assert-FileContains "Acceptance tests enforce space-manager sharing rights" `
"Server/tests/acceptance/features/apiSpacesShares/shareSpaces.feature" `
"manager role can share[\s\S]*editor or viewer role cannot share"
Assert-FileContains "Acceptance tests enforce project-space public-link sharing rights" `
"Server/tests/acceptance/features/apiSpacesShares/shareSubItemOfSpaceViaPublicLink.feature" `
"space manager role can share[\s\S]*without space manager role cannot share"
Write-Host "QSfera sharing-controls coverage check passed."