Compare commits

...

33 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
Курнат Андрей 89a8f32649 Show Android public link safety status 2026-06-10 07:51:01 +03:00
Курнат Андрей abdbf47789 Publish Android release 1.3.12 2026-06-10 07:42:22 +03:00
Курнат Андрей d79f4b93d0 Store Android auto-upload cursors per source 2026-06-10 07:35:14 +03:00
Курнат Андрей f1cbf65a3c Queue desktop tray show action 2026-06-10 07:24:48 +03:00
Курнат Андрей 371f94b669 Update Android production verification to 1.3.11 2026-06-10 07:09:06 +03:00
Курнат Андрей 73bb8a62bd Bump Android release to 1.3.11 2026-06-10 07:07:21 +03:00
Курнат Андрей 1a76143c2e Add Android backup status summary 2026-06-10 07:05:27 +03:00
Курнат Андрей 78a13fe148 Update Android production verification to 1.3.10 2026-06-10 06:58:30 +03:00
Курнат Андрей 0acda06d5e Bump Android release to 1.3.10 2026-06-10 06:56:07 +03:00
Курнат Андрей 4f69012249 Document Android debug-signed rollout policy 2026-06-10 06:52:29 +03:00
Курнат Андрей 0961491d52 Add safe Windows desktop feed publication 2026-06-10 06:43:26 +03:00
Курнат Андрей 0ee974fa38 Add Windows desktop packaging script 2026-06-10 06:34:34 +03:00
Курнат Андрей 8f61d0ba8c Fix Windows tray show handling 2026-06-10 05:52:40 +03:00
Курнат Андрей d64957dfcd Publish Android 1.3.9 and repair Windows autostart 2026-06-10 05:39:50 +03:00
Курнат Андрей 8337bcb742 Clarify Android video mobile data setting 2026-06-10 05:32:19 +03:00
Курнат Андрей c1c72900e5 Publish Android 1.3.8 to Argus 2026-06-09 22:01:02 +03:00
55 changed files with 4857 additions and 391 deletions
+1
View File
@@ -0,0 +1 @@
artifacts/
+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
+28
View File
@@ -0,0 +1,28 @@
# QSfera Android Argus Rollout Policy
## Sources
- 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.
- 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. 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 = 35
versionName = "1.3.7"
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
}
}
@@ -0,0 +1,338 @@
/**
* 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 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 {
fun format(
context: Context,
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 {
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),
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),
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)
} else {
context.resources.getQuantityString(
R.plurals.prefs_camera_upload_status_source_count,
sourceCount,
sourceCount
)
}
private fun formatConditions(context: Context, configuration: FolderBackUpConfiguration): String {
val conditions = mutableListOf<String>()
if (configuration.wifiOnly) {
conditions += context.getString(R.string.prefs_camera_upload_status_wifi_only)
} else {
conditions += context.getString(
R.string.prefs_camera_upload_status_mobile_data,
formatMobileDataLimit(context, configuration.mobileDataLimitBytes)
)
if (configuration.allowRoaming) {
conditions += context.getString(R.string.prefs_camera_upload_status_roaming_allowed)
}
}
if (configuration.chargingOnly) {
conditions += context.getString(R.string.prefs_camera_upload_status_charging_only)
}
return conditions.joinToString(separator = " - ")
}
private fun formatMobileDataLimit(context: Context, mobileDataLimitBytes: Long): String {
val values = context.resources.getStringArray(R.array.prefs_camera_upload_mobile_data_limit_values)
val entries = context.resources.getStringArray(R.array.prefs_camera_upload_mobile_data_limit_entries)
val index = values.indexOf(mobileDataLimitBytes.toString())
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
@@ -67,6 +68,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private val picturesViewModel by viewModel<SettingsPictureUploadsViewModel>()
private val manageAccountsViewModel by viewModel<ManageAccountsViewModel>()
private var prefPictureUploadsStatus: Preference? = null
private var prefEnablePictureUploads: SwitchPreferenceCompat? = null
private var prefPictureUploadsPath: Preference? = null
private var prefPictureUploadsOnWifi: CheckBoxPreference? = null
@@ -101,6 +103,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_picture_uploads, rootKey)
prefPictureUploadsStatus = findPreference(PREF_PICTURE_UPLOADS_STATUS)
prefEnablePictureUploads = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ENABLED)
prefPictureUploadsPath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH)
prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY)
@@ -136,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()
@@ -156,13 +165,30 @@ 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()
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
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,
transferSummary = transferSummary,
),
)
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
@@ -179,7 +205,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
}
}
}
private fun initPreferenceListeners() {
prefEnablePictureUploads?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
@@ -314,6 +339,12 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
private fun resetFields() {
prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
context = requireContext(),
configuration = null,
sourcePaths = emptyList(),
lastSyncSummary = getString(R.string.prefs_camera_upload_last_sync_never),
)
prefPictureUploadsAccount?.value = null
prefPictureUploadsPath?.summary = null
prefPictureUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
@@ -326,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 ->
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 =
@@ -343,6 +393,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
companion object {
private const val PREF_PICTURE_UPLOADS_STATUS = "picture_uploads_status"
private const val PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS = "picture_uploads_clear_source_paths"
}
}
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
@@ -40,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
@@ -58,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,
@@ -68,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()
}
@@ -196,13 +217,22 @@ class SettingsPictureUploadsViewModel(
val previousSourcePaths = getPictureUploadsSourcePaths()
val newSourcePath = contentUriForTree.toString()
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
val previousSourceSyncTimestamps = _pictureUploads.value?.sourceSyncTimestamps.orEmpty()
val fallbackLastSyncTimestamp = _pictureUploads.value?.lastSyncTimestamp ?: initialSyncTimestamp
val updatedSourceSyncTimestamps = updatedSourcePaths.associateWith { sourcePath ->
previousSourceSyncTimestamps[sourcePath] ?: if (sourcePath in previousSourcePaths) {
fallbackLastSyncTimestamp
} else {
initialSyncTimestamp
}
}
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
)
)
)
@@ -215,6 +245,7 @@ class SettingsPictureUploadsViewModel(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(
sourcePath = "",
sourcePathSyncTimestamps = "",
timestamp = System.currentTimeMillis()
)
)
@@ -237,6 +268,7 @@ class SettingsPictureUploadsViewModel(
mobileDataBytesQueuedInPeriod: Long? = _pictureUploads.value?.mobileDataBytesQueuedInPeriod,
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
sourcePath: String? = _pictureUploads.value?.sourcePath,
sourcePathSyncTimestamps: String? = _pictureUploads.value?.sourcePathSyncTimestamps,
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
spaceId: String? = _pictureUploads.value?.spaceId,
@@ -252,6 +284,7 @@ class SettingsPictureUploadsViewModel(
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
name = _pictureUploads.value?.name ?: pictureUploadsName,
spaceId = spaceId,
).also {
@@ -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
@@ -67,6 +68,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private val videosViewModel by viewModel<SettingsVideoUploadsViewModel>()
private val manageAccountsViewModel by viewModel<ManageAccountsViewModel>()
private var prefVideoUploadsStatus: Preference? = null
private var prefEnableVideoUploads: SwitchPreferenceCompat? = null
private var prefVideoUploadsPath: Preference? = null
private var prefVideoUploadsOnWifi: CheckBoxPreference? = null
@@ -101,6 +103,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_video_uploads, rootKey)
prefVideoUploadsStatus = findPreference(PREF_VIDEO_UPLOADS_STATUS)
prefEnableVideoUploads = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ENABLED)
prefVideoUploadsPath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH)
prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY)
@@ -134,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()
@@ -154,13 +163,30 @@ 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()
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
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,
transferSummary = transferSummary,
),
)
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
@@ -177,7 +203,6 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
}
}
}
private fun initPreferenceListeners() {
prefEnableVideoUploads?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
@@ -312,6 +337,12 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
private fun resetFields() {
prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
context = requireContext(),
configuration = null,
sourcePaths = emptyList(),
lastSyncSummary = getString(R.string.prefs_camera_upload_last_sync_never),
)
prefVideoUploadsAccount?.value = null
prefVideoUploadsPath?.summary = null
prefVideoUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
@@ -324,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 ->
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 =
@@ -341,6 +391,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
companion object {
private const val PREF_VIDEO_UPLOADS_STATUS = "video_uploads_status"
private const val PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS = "video_uploads_clear_source_paths"
}
}
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
@@ -40,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
@@ -58,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,
@@ -68,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()
}
@@ -196,13 +217,22 @@ class SettingsVideoUploadsViewModel(
val previousSourcePaths = getVideoUploadsSourcePaths()
val newSourcePath = contentUriForTree.toString()
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
val previousSourceSyncTimestamps = _videoUploads.value?.sourceSyncTimestamps.orEmpty()
val fallbackLastSyncTimestamp = _videoUploads.value?.lastSyncTimestamp ?: initialSyncTimestamp
val updatedSourceSyncTimestamps = updatedSourcePaths.associateWith { sourcePath ->
previousSourceSyncTimestamps[sourcePath] ?: if (sourcePath in previousSourcePaths) {
fallbackLastSyncTimestamp
} else {
initialSyncTimestamp
}
}
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
)
)
)
@@ -215,6 +245,7 @@ class SettingsVideoUploadsViewModel(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(
sourcePath = "",
sourcePathSyncTimestamps = "",
timestamp = System.currentTimeMillis()
)
)
@@ -237,6 +268,7 @@ class SettingsVideoUploadsViewModel(
mobileDataBytesQueuedInPeriod: Long? = _videoUploads.value?.mobileDataBytesQueuedInPeriod,
chargingOnly: Boolean? = _videoUploads.value?.chargingOnly,
sourcePath: String? = _videoUploads.value?.sourcePath,
sourcePathSyncTimestamps: String? = _videoUploads.value?.sourcePathSyncTimestamps,
behavior: UploadBehavior? = _videoUploads.value?.behavior,
timestamp: Long? = _videoUploads.value?.lastSyncTimestamp,
spaceId: String? = _videoUploads.value?.spaceId,
@@ -253,6 +285,7 @@ class SettingsVideoUploadsViewModel(
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
name = _videoUploads.value?.name ?: videoUploadsName,
spaceId = spaceId,
).also {
@@ -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 ->
@@ -123,6 +126,15 @@ class AppUpdateManager(private val fragment: Fragment) {
}
private fun showErrorDialog(context: Context, error: Throwable) {
if (error is SignatureMismatchException) {
AlertDialog.Builder(context)
.setTitle(R.string.update_reinstall_required_title)
.setMessage(R.string.update_reinstall_required_message)
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
AlertDialog.Builder(context)
.setTitle(R.string.common_error)
.setMessage(context.getString(R.string.update_error, error.message ?: error.javaClass.simpleName))
@@ -130,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}"
}
@@ -161,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 {
@@ -179,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))
}
@@ -211,12 +252,15 @@ 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))
}
if (!hasTrustedSignature(context, downloadedPackageInfo)) {
error(context.getString(R.string.update_signature_error))
throw SignatureMismatchException(context.getString(R.string.update_signature_error))
}
}
@@ -317,17 +361,41 @@ 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
)
private class SignatureMismatchException(message: String) : IllegalStateException(message)
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
@@ -0,0 +1,54 @@
package eu.qsfera.android.presentation.sharing.shares
import eu.qsfera.android.domain.sharing.shares.model.OCShare
import eu.qsfera.android.lib.resources.shares.RemoteShare
internal object PublicShareStatusFormatter {
fun statusFor(share: OCShare): PublicShareStatus =
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
val canCreate = permissions hasPermission RemoteShare.CREATE_PERMISSION_FLAG
val canUpdate = permissions hasPermission RemoteShare.UPDATE_PERMISSION_FLAG
val canDelete = permissions hasPermission RemoteShare.DELETE_PERMISSION_FLAG
val hasCustomFlags = permissions and PUBLIC_LINK_ACCESS_MASK.inv() != 0
return when {
hasCustomFlags -> PublicShareAccessMode.CUSTOM
canCreate && !canRead && !canUpdate && !canDelete -> PublicShareAccessMode.UPLOAD_ONLY
canRead && canCreate && canUpdate && canDelete -> PublicShareAccessMode.READ_AND_WRITE
canRead && !canCreate && !canUpdate && !canDelete -> PublicShareAccessMode.READ_ONLY
else -> PublicShareAccessMode.CUSTOM
}
}
private infix fun Int.hasPermission(permissionFlag: Int): Boolean = this and permissionFlag == permissionFlag
private const val PUBLIC_LINK_ACCESS_MASK = RemoteShare.READ_PERMISSION_FLAG or
RemoteShare.CREATE_PERMISSION_FLAG or
RemoteShare.UPDATE_PERMISSION_FLAG or
RemoteShare.DELETE_PERMISSION_FLAG
}
internal data class PublicShareStatus(
val accessMode: PublicShareAccessMode,
val isPasswordProtected: Boolean,
val expirationDateInMillis: Long?,
val isEditableInOcsDialog: Boolean,
)
internal enum class PublicShareAccessMode {
READ_ONLY,
READ_AND_WRITE,
UPLOAD_ONLY,
CUSTOM,
}
@@ -30,7 +30,10 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter
import eu.qsfera.android.databinding.SharePublicLinkItemBinding
import eu.qsfera.android.domain.sharing.shares.model.OCShare
import eu.qsfera.android.R
import eu.qsfera.android.utils.PreferenceUtils
import java.text.DateFormat
import java.util.Date
/**
* Adapter to show a list of public links
@@ -42,8 +45,6 @@ class SharePublicLinkListAdapter(
private val listener: SharePublicLinkAdapterListener
) : ArrayAdapter<OCShare>(mContext, resource) {
private lateinit var binding: SharePublicLinkItemBinding
init {
publicLinks = publicLinks.sortedBy { it.name }
}
@@ -55,8 +56,8 @@ class SharePublicLinkListAdapter(
override fun getItemId(position: Int): Long = 0
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val inflator = mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
binding = SharePublicLinkItemBinding.inflate(inflator).apply {
val inflator = LayoutInflater.from(mContext)
val binding = SharePublicLinkItemBinding.inflate(inflator, parent, false).apply {
root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext)
}
@@ -65,6 +66,7 @@ class SharePublicLinkListAdapter(
// If there's no name, set the token as name
binding.publicLinkName.text = if (share.name.isNullOrEmpty()) share.token else share.name
binding.publicLinkDetails.text = formatPublicLinkDetails(share)
// bind listener to get link
binding.getPublicLinkButton.setOnClickListener { listener.copyOrSendPublicLink(publicLinks[position]) }
@@ -79,6 +81,34 @@ class SharePublicLinkListAdapter(
return binding.root
}
private fun formatPublicLinkDetails(share: OCShare): String {
val status = PublicShareStatusFormatter.statusFor(share)
val expirationText = status.expirationDateInMillis?.let {
mContext.getString(
R.string.share_public_link_status_expires,
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it))
)
} ?: mContext.getString(R.string.share_public_link_status_no_expiration)
return listOf(
mContext.getString(status.accessMode.stringResId),
if (status.isPasswordProtected) {
mContext.getString(R.string.share_public_link_status_password_protected)
} else {
mContext.getString(R.string.share_public_link_status_no_password)
},
expirationText
).joinToString(separator = "")
}
private val PublicShareAccessMode.stringResId: Int
get() = when (this) {
PublicShareAccessMode.READ_ONLY -> R.string.share_public_link_status_read_only
PublicShareAccessMode.READ_AND_WRITE -> R.string.share_public_link_status_read_and_write
PublicShareAccessMode.UPLOAD_ONLY -> R.string.share_public_link_status_upload_only
PublicShareAccessMode.CUSTOM -> R.string.share_public_link_status_custom
}
interface SharePublicLinkAdapterListener {
fun copyOrSendPublicLink(share: OCShare)
@@ -163,11 +163,14 @@ class AutomaticUploadsWorker(
var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp)
var mobileDataLimitReached = false
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
val sourcePaths = folderBackUpConfiguration.sourcePaths
val currentSourceSyncTimestamps = folderBackUpConfiguration.sourceSyncTimestamps
val localPicturesDocumentFiles: List<DocumentFile> = sourcePaths.flatMap { sourcePath ->
val lastSyncTimestamp = currentSourceSyncTimestamps[sourcePath] ?: folderBackUpConfiguration.lastSyncTimestamp
getFilesReadyToUpload(
syncType = syncType,
sourcePath = sourcePath,
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
lastSyncTimestamp = lastSyncTimestamp,
currentTimestamp = currentTimestamp,
)
}
@@ -241,7 +244,20 @@ class AutomaticUploadsWorker(
// so skipped files are re-evaluated after the quota period resets.
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
val nextTimestamp = if (mobileDataLimitReached) folderBackUpConfiguration.lastSyncTimestamp else safeTimestamp
updateConfigurationAfterScan(folderBackUpConfiguration, syncType, nextTimestamp, mobileDataQuotaState)
val nextSourceSyncTimestamps = resolveSourceSyncTimestampsAfterScan(
sourcePaths = sourcePaths,
currentSourceSyncTimestamps = currentSourceSyncTimestamps,
fallbackLastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
nextSyncTimestamp = nextTimestamp,
scanCompleted = !mobileDataLimitReached,
)
updateConfigurationAfterScan(
folderBackUpConfiguration = folderBackUpConfiguration,
syncType = syncType,
nextSyncTimestamp = nextTimestamp,
nextSourceSyncTimestamps = nextSourceSyncTimestamps,
mobileDataQuotaState = mobileDataQuotaState
)
}
private fun showNotification(
@@ -294,10 +310,12 @@ class AutomaticUploadsWorker(
folderBackUpConfiguration: FolderBackUpConfiguration,
syncType: SyncType,
nextSyncTimestamp: Long,
nextSourceSyncTimestamps: Map<String, Long>,
mobileDataQuotaState: MobileDataQuotaState,
) {
val updatedConfiguration = folderBackUpConfiguration.copy(
lastSyncTimestamp = nextSyncTimestamp,
sourcePathSyncTimestamps = FolderBackUpConfiguration.encodeSourcePathSyncTimestamps(nextSourceSyncTimestamps),
mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod
)
@@ -459,5 +477,22 @@ class AutomaticUploadsWorker(
): Boolean =
!shouldCountAgainstMobileDataLimit(wifiOnly, isActiveNetworkMetered, mobileDataLimitBytes) ||
fileSize <= mobileDataLimitBytes - mobileDataBytesQueuedInPeriod
fun resolveSourceSyncTimestampsAfterScan(
sourcePaths: List<String>,
currentSourceSyncTimestamps: Map<String, Long>,
fallbackLastSyncTimestamp: Long,
nextSyncTimestamp: Long,
scanCompleted: Boolean,
): Map<String, Long> =
sourcePaths
.distinct()
.associateWith { sourcePath ->
if (scanCompleted) {
nextSyncTimestamp
} else {
currentSourceSyncTimestamps[sourcePath] ?: fallbackLastSyncTimestamp
}
}
}
}
@@ -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"
@@ -16,7 +16,7 @@
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
@@ -25,8 +25,7 @@
android:orientation="horizontal"
android:weightSum="1">
<TextView
android:id="@+id/publicLinkName"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
@@ -34,12 +33,29 @@
android:layout_marginStart="@dimen/standard_half_margin"
android:layout_marginTop="@dimen/standard_half_margin"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/publicLinkName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
android:text="@string/username"
android:textColor="@color/textColor"
android:textSize="@dimen/two_line_primary_text_size" />
<TextView
android:id="@+id/publicLinkDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/list_item_lastmod_and_filesize_text"
android:textSize="@dimen/two_line_secondary_text_size" />
</LinearLayout>
<ImageButton
android:id="@+id/getPublicLinkButton"
android:layout_width="48dp"
@@ -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>
@@ -123,6 +123,8 @@
<string name="update_version_error">Скачанное обновление не новее установленного приложения.</string>
<string name="update_metadata_error">Скачанное обновление не совпадает с метаданными релиза в Аргус.</string>
<string name="update_signature_error">Скачанное обновление подписано не сертификатом установленного приложения.</string>
<string name="update_reinstall_required_title">Требуется переустановка</string>
<string name="update_reinstall_required_message">Эта установленная сборка подписана другим сертификатом, чем текущий релиз QSfera в Аргус. Android не может обновить её поверх установленной. Сохраните несинхронизированные файлы, удалите эту сборку и установите QSfera из Аргус.</string>
<string name="update_apk_parse_error">Скачанное обновление не удалось прочитать как Android-пакет.</string>
<string name="update_release_not_found">Последний Android-релиз не найден.</string>
<string name="pattern_enter_pattern">Пожалуйста, введите графический ключ</string>
@@ -561,6 +563,36 @@
<string name="prefs_camera_upload_source_paths_empty">Папки телефона не выбраны</string>
<string name="confirmation_clear_camera_upload_sources_title">Убрать выбранные папки</string>
<string name="confirmation_clear_camera_upload_sources_message">Копирование перестанет проверять папки телефона, пока вы снова не добавите папку.</string>
<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>
@@ -592,6 +624,15 @@
<string name="share_via_link_edit_permission_read_only_label">Скачивание / Просмотр</string>
<string name="share_via_link_edit_permission_read_and_write_label">Скачивание / Просмотр / Закачка</string>
<string name="share_via_link_edit_permission_upload_only_label">Только закачка (перетащить файл сюда)</string>
<string name="share_public_link_status_read_only">Скачивание / просмотр</string>
<string name="share_public_link_status_read_and_write">Скачивание / просмотр / загрузка</string>
<string name="share_public_link_status_upload_only">Только загрузка</string>
<string name="share_public_link_status_custom">Особые права</string>
<string name="share_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>
<string name="share_public_link_status_no_expiration">Без срока действия</string>
<string name="share_get_public_link_button">Получить ссылку</string>
<string name="share_with_title">Поделиться с</string>
<string name="share_with_edit_title">Поделиться с %1$s</string>
@@ -76,7 +76,8 @@
<string name="prefs_camera_video_upload">Back up videos</string>
<string name="prefs_camera_video_upload_summary">Automatically back up videos from selected phone folders</string>
<string name="prefs_camera_video_upload_path_title">QSfera destination folder</string>
<string name="prefs_camera_video_upload_on_wifi">Use Wi-Fi only for video backup</string>
<string name="prefs_camera_video_upload_on_wifi">Never use mobile data for videos</string>
<string name="prefs_camera_video_upload_on_wifi_summary">Recommended for large videos. Turn off to allow metered-network limits and roaming controls.</string>
<string name="prefs_camera_video_upload_on_charging">Back up videos only while charging</string>
<string name="prefs_automatic_uploads_not_available_users_light">Automatic uploads are not available for users light</string>
<string name="prefs_subsection_advanced">Advanced</string>
@@ -111,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>
@@ -124,6 +125,8 @@
<string name="update_version_error">Downloaded update is not newer than the installed app.</string>
<string name="update_metadata_error">Downloaded update does not match Argus release metadata.</string>
<string name="update_signature_error">Downloaded update is not signed with the installed app certificate.</string>
<string name="update_reinstall_required_title">Reinstall required</string>
<string name="update_reinstall_required_message">This installed build was signed with a different certificate than the current QSfera release in Argus. Android cannot update it in place. Back up unsynced files, uninstall this build, then install QSfera from Argus.</string>
<string name="update_apk_parse_error">Downloaded update could not be read as an Android package.</string>
<string name="update_release_not_found">Latest Android release was not found.</string>
<string name="pattern_enter_pattern">Please enter your pattern</string>
@@ -599,6 +602,34 @@
<string name="prefs_camera_upload_source_paths_empty">No phone folders selected</string>
<string name="confirmation_clear_camera_upload_sources_title">Remove selected folders</string>
<string name="confirmation_clear_camera_upload_sources_message">Backup will stop scanning phone folders until you add a folder again.</string>
<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>
@@ -648,6 +679,15 @@
<string name="share_via_link_edit_permission_read_only_label">Download / View</string>
<string name="share_via_link_edit_permission_read_and_write_label">Download/View/Upload</string>
<string name="share_via_link_edit_permission_upload_only_label">Upload Only (File Drop)</string>
<string name="share_public_link_status_read_only">Download / View</string>
<string name="share_public_link_status_read_and_write">Download / View / Upload</string>
<string name="share_public_link_status_upload_only">Upload only</string>
<string name="share_public_link_status_custom">Custom access</string>
<string name="share_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>
<string name="share_public_link_status_no_expiration">No expiration</string>
<string name="share_get_public_link_button">Get link</string>
<string name="share_with_title">Share with</string>
<string name="share_with_edit_title">Share with %1$s</string>
@@ -25,6 +25,12 @@
app:key="enable_picture_uploads"
app:summary="@string/prefs_camera_picture_upload_summary"
app:title="@string/prefs_camera_picture_upload" />
<Preference
app:iconSpaceReserved="false"
app:key="picture_uploads_status"
app:selectable="false"
app:summary="@string/prefs_camera_upload_status_off"
app:title="@string/prefs_camera_upload_status_title" />
<ListPreference
app:dialogTitle="@string/prefs_picture_upload_account"
app:iconSpaceReserved="false"
@@ -23,6 +23,12 @@
app:key="enable_video_uploads"
app:summary="@string/prefs_camera_video_upload_summary"
app:title="@string/prefs_camera_video_upload" />
<Preference
app:iconSpaceReserved="false"
app:key="video_uploads_status"
app:selectable="false"
app:summary="@string/prefs_camera_upload_status_off"
app:title="@string/prefs_camera_upload_status_title" />
<ListPreference
app:dialogTitle="@string/prefs_video_upload_account"
app:iconSpaceReserved="false"
@@ -61,6 +67,7 @@
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="video_uploads_on_wifi"
app:summary="@string/prefs_camera_video_upload_on_wifi_summary"
app:title="@string/prefs_camera_video_upload_on_wifi" />
<CheckBoxPreference
app:iconSpaceReserved="false"
@@ -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"
}
}
@@ -0,0 +1,68 @@
package eu.qsfera.android.presentation.sharing.shares
import eu.qsfera.android.lib.resources.shares.RemoteShare
import eu.qsfera.android.testutil.OC_PUBLIC_SHARE
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PublicShareStatusFormatterTest {
@Test
fun `statusFor reports read-only public links without password or expiration`() {
val share = OC_PUBLIC_SHARE.copy(
permissions = RemoteShare.READ_PERMISSION_FLAG,
shareWith = "",
expirationDate = 0
)
val status = PublicShareStatusFormatter.statusFor(share)
assertEquals(PublicShareAccessMode.READ_ONLY, status.accessMode)
assertFalse(status.isPasswordProtected)
assertNull(status.expirationDateInMillis)
assertTrue(status.isEditableInOcsDialog)
}
@Test
fun `statusFor reports password-protected read-write public links with expiration`() {
val expirationDate = 1_556_575_200_000L
val share = OC_PUBLIC_SHARE.copy(
permissions = RemoteShare.READ_PERMISSION_FLAG or
RemoteShare.CREATE_PERMISSION_FLAG or
RemoteShare.UPDATE_PERMISSION_FLAG or
RemoteShare.DELETE_PERMISSION_FLAG,
shareWith = "password-placeholder",
expirationDate = expirationDate
)
val status = PublicShareStatusFormatter.statusFor(share)
assertEquals(PublicShareAccessMode.READ_AND_WRITE, status.accessMode)
assertTrue(status.isPasswordProtected)
assertEquals(expirationDate, status.expirationDateInMillis)
}
@Test
fun `statusFor reports upload-only public links`() {
val share = OC_PUBLIC_SHARE.copy(permissions = RemoteShare.CREATE_PERMISSION_FLAG)
val status = PublicShareStatusFormatter.statusFor(share)
assertEquals(PublicShareAccessMode.UPLOAD_ONLY, status.accessMode)
}
@Test
fun `statusFor reports custom public links for non-standard permission sets`() {
val share = OC_PUBLIC_SHARE.copy(
permissions = RemoteShare.READ_PERMISSION_FLAG or RemoteShare.SHARE_PERMISSION_FLAG
)
val status = PublicShareStatusFormatter.statusFor(share)
assertEquals(PublicShareAccessMode.CUSTOM, status.accessMode)
assertFalse(status.isEditableInOcsDialog)
}
}
@@ -121,6 +121,47 @@ class AutomaticUploadsWorkerTest {
)
}
@Test
fun `completed scan advances current source sync timestamps and drops removed sources`() {
val timestamps = AutomaticUploadsWorker.resolveSourceSyncTimestampsAfterScan(
sourcePaths = listOf("content://camera", "content://screenshots", "content://camera"),
currentSourceSyncTimestamps = mapOf(
"content://camera" to 100L,
"content://old-folder" to 200L,
),
fallbackLastSyncTimestamp = 50L,
nextSyncTimestamp = 1_000L,
scanCompleted = true,
)
assertEquals(
mapOf(
"content://camera" to 1_000L,
"content://screenshots" to 1_000L,
),
timestamps
)
}
@Test
fun `interrupted scan keeps source sync timestamps and falls back to global timestamp`() {
val timestamps = AutomaticUploadsWorker.resolveSourceSyncTimestampsAfterScan(
sourcePaths = listOf("content://camera", "content://screenshots"),
currentSourceSyncTimestamps = mapOf("content://camera" to 100L),
fallbackLastSyncTimestamp = 50L,
nextSyncTimestamp = 1_000L,
scanCompleted = false,
)
assertEquals(
mapOf(
"content://camera" to 100L,
"content://screenshots" to 50L,
),
timestamps
)
}
private fun backupConfiguration(
mobileDataPeriodStartTimestamp: Long,
mobileDataBytesQueuedInPeriod: Long,
@@ -137,6 +178,7 @@ class AutomaticUploadsWorkerTest {
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = false,
lastSyncTimestamp = 0L,
sourcePathSyncTimestamps = "",
name = FolderBackUpConfiguration.pictureUploadsName,
spaceId = null,
)
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ public class ProviderMeta {
public static final String DB_NAME = "filelist";
public static final String NEW_DB_NAME = "qsfera_database";
public static final int DB_VERSION = 51;
public static final int DB_VERSION = 52;
private ProviderMeta() {
}
@@ -54,6 +54,7 @@ import eu.qsfera.android.data.migrations.MIGRATION_47_48
import eu.qsfera.android.data.migrations.MIGRATION_48_49
import eu.qsfera.android.data.migrations.MIGRATION_49_50
import eu.qsfera.android.data.migrations.MIGRATION_50_51
import eu.qsfera.android.data.migrations.MIGRATION_51_52
import eu.qsfera.android.data.sharing.shares.db.OCShareDao
import eu.qsfera.android.data.sharing.shares.db.OCShareEntity
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
@@ -131,7 +132,8 @@ abstract class QSferaDatabase : RoomDatabase() {
MIGRATION_47_48,
MIGRATION_48_49,
MIGRATION_49_50,
MIGRATION_50_51)
MIGRATION_50_51,
MIGRATION_51_52)
.build()
INSTANCE = instance
instance
@@ -75,6 +75,7 @@ class OCLocalFolderBackupDataSource(
chargingOnly = chargingOnly,
name = name,
lastSyncTimestamp = lastSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
spaceId = spaceId,
)
@@ -93,6 +94,7 @@ class OCLocalFolderBackupDataSource(
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly,
lastSyncTimestamp = lastSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
name = name,
spaceId = spaceId,
)
@@ -36,6 +36,7 @@ data class FolderBackUpEntity(
val chargingOnly: Boolean,
val name: String,
val lastSyncTimestamp: Long,
val sourcePathSyncTimestamps: String,
val spaceId: String?,
) {
@PrimaryKey(autoGenerate = true) var id: Int = 0
@@ -54,6 +54,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR),
lastSyncTimestamp = timestamp,
sourcePathSyncTimestamps = "",
name = pictureUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
@@ -74,6 +75,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR),
lastSyncTimestamp = timestamp,
sourcePathSyncTimestamps = "",
name = videoUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
@@ -0,0 +1,11 @@
package eu.qsfera.android.data.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import eu.qsfera.android.data.ProviderMeta.ProviderTableMeta.FOLDER_BACKUP_TABLE_NAME
val MIGRATION_51_52 = object : Migration(51, 52) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN sourcePathSyncTimestamps TEXT NOT NULL DEFAULT ''")
}
}
@@ -19,6 +19,9 @@
package eu.qsfera.android.domain.automaticuploads.model
import java.net.URLDecoder
import java.net.URLEncoder
data class FolderBackUpConfiguration(
val accountName: String,
val behavior: UploadBehavior,
@@ -31,6 +34,7 @@ data class FolderBackUpConfiguration(
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean,
val lastSyncTimestamp: Long,
val sourcePathSyncTimestamps: String,
val name: String,
val spaceId: String?,
) {
@@ -38,12 +42,15 @@ data class FolderBackUpConfiguration(
val isPictureUploads get() = name == pictureUploadsName
val isVideoUploads get() = name == videoUploadsName
val sourcePaths get() = parseSourcePaths(sourcePath)
val sourceSyncTimestamps get() = parseSourcePathSyncTimestamps(sourcePathSyncTimestamps)
companion object {
const val pictureUploadsName = "Picture uploads"
const val videoUploadsName = "Video uploads"
const val initialSyncTimestamp = 0L
private const val SOURCE_PATH_SEPARATOR = "\n"
private const val SOURCE_PATH_TIMESTAMP_SEPARATOR = "\t"
private const val UTF_8 = "UTF-8"
fun parseSourcePaths(sourcePath: String): List<String> =
sourcePath
@@ -58,6 +65,49 @@ data class FolderBackUpConfiguration(
.filter { it.isNotEmpty() }
.distinct()
.joinToString(SOURCE_PATH_SEPARATOR)
fun parseSourcePathSyncTimestamps(sourcePathSyncTimestamps: String): Map<String, Long> =
sourcePathSyncTimestamps
.split(SOURCE_PATH_SEPARATOR)
.map { it.trim() }
.filter { it.isNotEmpty() }
.fold(LinkedHashMap<String, Long>()) { acc, encodedEntry ->
val separatorIndex = encodedEntry.indexOf(SOURCE_PATH_TIMESTAMP_SEPARATOR)
if (separatorIndex <= 0) {
return@fold acc
}
val sourcePath = decodeSourcePath(encodedEntry.substring(0, separatorIndex)) ?: return@fold acc
val timestamp = encodedEntry.substring(separatorIndex + 1).toLongOrNull() ?: return@fold acc
if (sourcePath.isNotBlank() && timestamp >= initialSyncTimestamp) {
acc[sourcePath] = timestamp
}
acc
}
fun encodeSourcePathSyncTimestamps(sourceSyncTimestamps: Map<String, Long>): String =
sourceSyncTimestamps
.entries
.fold(LinkedHashMap<String, Long>()) { acc, (sourcePath, timestamp) ->
val normalizedSourcePath = sourcePath.trim()
if (normalizedSourcePath.isNotEmpty() && timestamp >= initialSyncTimestamp) {
acc[normalizedSourcePath] = timestamp
}
acc
}
.map { (sourcePath, timestamp) ->
"${encodeSourcePath(sourcePath)}$SOURCE_PATH_TIMESTAMP_SEPARATOR$timestamp"
}
.joinToString(SOURCE_PATH_SEPARATOR)
private fun encodeSourcePath(sourcePath: String): String =
URLEncoder.encode(sourcePath, UTF_8)
private fun decodeSourcePath(encodedSourcePath: String): String? =
try {
URLDecoder.decode(encodedSourcePath, UTF_8)
} catch (illegalArgumentException: IllegalArgumentException) {
null
}
}
}
@@ -11,7 +11,9 @@
package eu.qsfera.android.domain.automaticuploads.model
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePathSyncTimestamps
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.parseSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.parseSourcePathSyncTimestamps
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -59,4 +61,33 @@ class FolderBackUpConfigurationTest {
assertEquals("$firstSourcePath\n$secondSourcePath", encodedSourcePaths)
}
@Test
fun `source path sync timestamps round trip encoded source paths`() {
val firstSourcePath = "content://com.android.externalstorage.documents/tree/primary%3ADCIM"
val secondSourcePath = "content://com.android.externalstorage.documents/tree/primary%3AScreenshots"
val sourceSyncTimestamps = linkedMapOf(
firstSourcePath to 100L,
secondSourcePath to 200L,
)
val encodedSourceSyncTimestamps = encodeSourcePathSyncTimestamps(sourceSyncTimestamps)
val parsedSourceSyncTimestamps = parseSourcePathSyncTimestamps(encodedSourceSyncTimestamps)
assertEquals(sourceSyncTimestamps, parsedSourceSyncTimestamps)
}
@Test
fun `parseSourcePathSyncTimestamps ignores malformed entries`() {
val sourcePath = "content://com.android.externalstorage.documents/tree/primary%3ADCIM"
val encodedSourceSyncTimestamps = """
malformed
${java.net.URLEncoder.encode(sourcePath, "UTF-8")} not-a-number
${java.net.URLEncoder.encode(sourcePath, "UTF-8")} 123
""".trimIndent()
val parsedSourceSyncTimestamps = parseSourcePathSyncTimestamps(encodedSourceSyncTimestamps)
assertEquals(mapOf(sourcePath to 123L), parsedSourceSyncTimestamps)
}
}
@@ -37,6 +37,7 @@ val OC_BACKUP = FolderBackUpConfiguration(
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true,
lastSyncTimestamp = 1542628397,
sourcePathSyncTimestamps = "",
name = "",
spaceId = null,
)
@@ -53,6 +54,7 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity(
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true,
lastSyncTimestamp = 1542628397,
sourcePathSyncTimestamps = "",
name = "",
spaceId = null,
)
+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
+350 -119
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
}
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
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"
}
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'."
}
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) {
throw "Package file was not found: $resolvedPackagePath"
return [string]$property.Value
}
$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 Quote-BashSingle([string]$Value) {
if ($null -eq $Value) {
$Value = ''
}
return "'" + ($Value -replace "'", "'\''") + "'"
}
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
}
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 {
$fileStream.Dispose()
$form.Dispose()
Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue
}
} finally {
$client.Dispose()
$handler.Dispose()
$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
}
}
+21 -3
View File
@@ -36,11 +36,29 @@ feed with a concrete version:
</QSfera>
```
Validate the live feed before a Windows production release:
From the repository root, prepare the feed XML from a signed MSI and optionally
upload the MSI/feed to the web host:
```powershell
./scripts/validate-desktop-update-feed.ps1 -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
./scripts/validate-production-release.ps1 -VerifyUpdateFeed
.\scripts\package-windows-desktop.ps1
.\scripts\publish-windows-desktop.ps1 -MsiPath ".\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi"
.\scripts\publish-windows-desktop.ps1 `
-MsiPath ".\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi" `
-RemoteHost "192.168.0.185" `
-RemoteDownloadDirectory "/var/www/qsfera/desktop/downloads" `
-RemoteFeedPath "/var/www/qsfera/desktop/updates/stable.xml"
```
`publish-windows-desktop.ps1` refuses remote publication when the MSI is not
Authenticode-signed. `-SkipSignatureCheck` is only for local feed validation and
is rejected when `-RemoteHost` is set.
From the repository root, validate the feed before a Windows production release:
```powershell
.\Desktop\scripts\validate-desktop-update-feed.ps1 -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
.\Desktop\scripts\validate-desktop-update-feed.ps1 -Path ".\artifacts\windows-desktop\publish\stable.xml"
.\Desktop\scripts\validate-production-release.ps1 -VerifyUpdateFeed
```
For a stable signed tag release, `validate-production-release.ps1 -RequireSigning`
@@ -1,6 +1,10 @@
[CmdletBinding(DefaultParameterSetName = "Url")]
param(
[Parameter(Mandatory = $true)]
[string]$Url
[Parameter(Mandatory = $true, ParameterSetName = "Url")]
[string]$Url,
[Parameter(Mandatory = $true, ParameterSetName = "Path")]
[string]$Path
)
$ErrorActionPreference = "Stop"
@@ -10,6 +14,10 @@ function Fail($Message) {
exit 1
}
$feedSource = $null
$feedContent = $null
if ($PSCmdlet.ParameterSetName -eq "Url") {
$parsedUrl = $null
if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$parsedUrl)) {
Fail "Desktop update feed URL is not absolute: $Url"
@@ -29,8 +37,19 @@ if ($response.StatusCode -ne 200) {
Fail "Desktop update feed must return HTTP 200. Actual status: $($response.StatusCode)"
}
$feedSource = $Url
$feedContent = $response.Content
} else {
if (-not (Test-Path -LiteralPath $Path)) {
Fail "Desktop update feed file was not found: $Path"
}
$feedSource = (Resolve-Path -LiteralPath $Path).Path
$feedContent = Get-Content -LiteralPath $feedSource -Raw
}
try {
[xml]$xml = $response.Content
[xml]$xml = $feedContent
} catch {
Fail "Desktop update feed is not valid XML: $($_.Exception.Message)"
}
@@ -82,4 +101,4 @@ if ([string]::IsNullOrWhiteSpace($version)) {
}
}
Write-Host "QSfera Desktop update feed validation passed: $Url"
Write-Host "QSfera Desktop update feed validation passed: $feedSource"
+54 -9
View File
@@ -48,8 +48,12 @@
#endif
#include <QApplication>
#include <QCursor>
#include <QDesktopServices>
#include <QGuiApplication>
#include <QMenuBar>
#include <QScreen>
#include <QWindow>
using namespace Qt::Literals::StringLiterals;
using namespace OCC;
@@ -97,6 +101,32 @@ void setUpInitialSyncFolder(AccountStatePtr accountStatePtr, bool useVfs)
Qt::SingleShotConnection);
accountStatePtr->account()->spacesManager()->checkReady();
}
void ensureWindowIntersectsAvailableScreen(QWidget *window)
{
if (!window) {
return;
}
const auto frame = window->frameGeometry();
for (const auto *screen : QGuiApplication::screens()) {
if (screen->availableGeometry().intersects(frame)) {
return;
}
}
auto *screen = QGuiApplication::screenAt(QCursor::pos());
if (!screen) {
screen = QGuiApplication::primaryScreen();
}
if (!screen) {
return;
}
const auto availableGeometry = screen->availableGeometry();
window->resize(window->size().boundedTo(availableGeometry.size()));
window->move(availableGeometry.center() - window->rect().center());
}
}
QString Application::displayLanguage() const
@@ -244,23 +274,38 @@ AccountStatePtr Application::addNewAccount(AccountPtr newAccount)
void Application::showSettings()
{
auto window = ocApp()->settingsDialog();
ensureWindowIntersectsAvailableScreen(window);
if (window->windowState().testFlag(Qt::WindowMinimized)) {
window->setWindowState(window->windowState() & ~Qt::WindowMinimized);
}
window->show();
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.
// Use a common hack to attach to the active application
const auto activeProcessId = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
if (activeProcessId != qApp->applicationPid()) {
const auto threadId = GetCurrentThreadId();
// don't step here with a debugger...
if (AttachThreadInput(threadId, activeProcessId, true)) {
// Use a common hack to attach to the active application.
const auto foregroundWindow = GetForegroundWindow();
const auto foregroundThreadId = foregroundWindow ? GetWindowThreadProcessId(foregroundWindow, nullptr) : 0;
const auto currentThreadId = GetCurrentThreadId();
const auto attachedToForegroundThread =
foregroundThreadId != 0 && foregroundThreadId != currentThreadId && AttachThreadInput(currentThreadId, foregroundThreadId, true);
const auto hwnd = reinterpret_cast<HWND>(window->winId());
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_NOACTIVATE);
AttachThreadInput(threadId, activeProcessId, false);
}
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
if (attachedToForegroundThread) {
AttachThreadInput(currentThreadId, foregroundThreadId, false);
}
#endif
}
+13 -2
View File
@@ -24,11 +24,21 @@
#include <QApplication>
#include <QDesktopServices>
#include <QMenu>
#include <QTimer>
using namespace Qt::Literals::StringLiterals;
namespace OCC {
namespace {
constexpr auto showSettingsAfterTrayEventDelay = 100;
void showSettingsAfterTrayEvent()
{
auto *app = ocApp();
QTimer::singleShot(showSettingsAfterTrayEventDelay, app, [app] { app->showSettings(); });
}
}
Systray::Systray(QObject *parent)
: QSystemTrayIcon(parent)
@@ -36,7 +46,7 @@ Systray::Systray(QObject *parent)
connect(this, &QSystemTrayIcon::activated, this, [](QSystemTrayIcon::ActivationReason reason) {
// Left click
if (reason == QSystemTrayIcon::Trigger) {
ocApp()->showSettings();
showSettingsAfterTrayEvent();
}
});
connect(AccountManager::instance(), &AccountManager::accountAdded, this,
@@ -143,7 +153,8 @@ void Systray::computeContextMenu()
Q_ASSERT(!contextMenu());
auto *menu = new QMenu(Theme::instance()->appNameGUI());
menu->addAction(Theme::instance()->applicationIcon(), tr("Show %1").arg(Theme::instance()->appNameGUI()), ocApp(), &Application::showSettings);
auto *showAction = menu->addAction(Theme::instance()->applicationIcon(), tr("Show %1").arg(Theme::instance()->appNameGUI()));
connect(showAction, &QAction::triggered, this, [] { showSettingsAfterTrayEvent(); });
auto *pauseResume = new QAction(menu);
auto updatePauseResumeAction = [pauseResume] {
pauseResume->setText(FolderMan::instance()->scheduler()->isRunning() ? tr("Pause synchronizations") : tr("Resume synchronizations"));
+8 -2
View File
@@ -27,6 +27,7 @@
#include "csync.h"
#include "syncfileitem.h"
#include <chrono>
#include <sys/stat.h>
#ifdef Q_OS_WIN32
@@ -74,7 +75,9 @@ time_t FileSystem::fileTimeToTime_t(std::filesystem::file_time_type fileTime)
#ifdef HAS_CLOCK_CAST
return std::chrono::system_clock::to_time_t(std::chrono::clock_cast<std::chrono::system_clock>(fileTime));
#else
const auto systemTime = std::chrono::time_point_cast<std::chrono::system_clock::duration>(std::chrono::file_clock::to_sys(fileTime));
const auto fileNow = std::filesystem::file_time_type::clock::now();
const auto systemNow = std::chrono::system_clock::now();
const auto systemTime = std::chrono::time_point_cast<std::chrono::system_clock::duration>(systemNow + (fileTime - fileNow));
return std::chrono::system_clock::to_time_t(systemTime);
#endif
}
@@ -83,7 +86,10 @@ std::filesystem::file_time_type FileSystem::time_tToFileTime(time_t fileTime)
#ifdef HAS_CLOCK_CAST
return std::chrono::clock_cast<std::chrono::file_clock>(std::chrono::system_clock::from_time_t(fileTime));
#else
return std::chrono::file_clock::from_sys(std::chrono::system_clock::from_time_t(fileTime));
const auto fileNow = std::filesystem::file_time_type::clock::now();
const auto systemNow = std::chrono::system_clock::now();
return std::chrono::time_point_cast<std::filesystem::file_time_type::duration>(
fileNow + (std::chrono::system_clock::from_time_t(fileTime) - systemNow));
#endif
}
+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"
+42 -18
View File
@@ -22,25 +22,53 @@ Date: 2026-06-08.
## Completed In This Pass
- 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.7` with `versionCode` `35`.
- 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 `1.3.7`/`35` was published to Argus as `qsfera-mobile` on 2026-06-08. Public manifest and latest download both report SHA-256 `c9c044c1332f8d973c64502ad2e3227ad92398101e7354ce9dbe7f708e7e693b`.
- Android version was raised to `1.3.12` with `versionCode` `40`.
- 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.
- Android new auto-upload configurations default to Wi-Fi-only transfers for photos and videos.
- Android auto-upload now exposes a no-roaming control for photo and video backup, stores it in Room schema version `50`, and maps mobile backup without roaming to WorkManager `NetworkType.NOT_ROAMING`.
- Android auto-upload now exposes a photo/video daily mobile-data limit, stores it in Room schema version `51`, and stops queuing additional uploads on metered automatic-upload runs after the configured daily byte limit is reached while keeping skipped files eligible for the next scan period.
- 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`.
- Windows/Desktop CI now validates production release invariants before Windows packaging and requires signing variables for stable tag releases.
- Windows/Desktop now has a configured HTTPS update feed at `https://qsfera.kusoft.xyz/desktop/updates/stable.xml`; the feed returned HTTP `200` on 2026-06-08 and contains the XML elements parsed by `Desktop/src/gui/updater/updateinfo.cpp`.
- 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.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-08: server `edition=stable`/`productversion=7.0.0`, Android `1.3.7`/`35`, downloaded APK size `12220848`, APK SHA-256 `c9c044c1332f8d973c64502ad2e3227ad92398101e7354ce9dbe7f708e7e693b`, and valid Desktop no-update feed.
- 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.
- 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`.
@@ -61,26 +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 `677fa0bc-07f8-4981-b7fe-98d6de283cb8`, version `1.3.7`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `35`, package size `12220848`, and SHA-256 `c9c044c1332f8d973c64502ad2e3227ad92398101e7354ce9dbe7f708e7e693b`.
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12220848` byte APK with the same SHA-256 `c9c044c1332f8d973c64502ad2e3227ad92398101e7354ce9dbe7f708e7e693b`.
- 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.7`/`35` 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 production-signed `1.3.7`.
- 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.7` 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`.
- Decide the rollout policy for any devices that installed the debug-signed Argus `1.3.5` APK. The production-signed `1.3.7` APK is the correct current release, but installed debug-signed clients will not satisfy the app's own signer-digest check for an in-place update.
- 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.
- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference.
- Publish the first signed Windows MSI 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>`.
- 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
- Add Google Photos-style backup status UX: overall status, per-item status, queued/paused/permanent-failure states, and low-storage messaging.
- Add a clearer video-specific "never use mobile data for videos" shortcut in the Android automatic-upload UI; videos are still Wi-Fi-only by default, and the new daily mobile-data limit applies to metered networks after Wi-Fi-only is turned off.
- Store auto-upload sync cursors per source folder rather than one timestamp for all folders.
- 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.
+394
View File
@@ -0,0 +1,394 @@
param(
[string]$RuntimeRoot = "$env:USERPROFILE\craft\CraftMaster\windows-cl-msvc2022-x86_64",
[string]$OutputRoot = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop"),
[string]$Version,
[switch]$SkipMsi
)
$ErrorActionPreference = "Stop"
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Get-QSferaDesktopVersion {
$versionFile = Join-Path $PSScriptRoot "..\Desktop\VERSION.cmake"
if (-not (Test-Path -LiteralPath $versionFile)) {
Fail "Desktop version file was not found: $versionFile"
}
$values = @{}
foreach ($line in Get-Content -LiteralPath $versionFile) {
if ($line -match '^\s*set\(\s*(MIRALL_VERSION_(MAJOR|MINOR|PATCH|BUILD))\s+"?([^"\s\)]+)"?\s*\)') {
$values[$matches[1]] = $matches[3]
}
}
foreach ($key in @("MIRALL_VERSION_MAJOR", "MIRALL_VERSION_MINOR", "MIRALL_VERSION_PATCH")) {
if (-not $values.ContainsKey($key)) {
Fail "Desktop version file does not define $key."
}
}
$baseVersion = "{0}.{1}.{2}" -f $values["MIRALL_VERSION_MAJOR"], $values["MIRALL_VERSION_MINOR"], $values["MIRALL_VERSION_PATCH"]
$build = $values["MIRALL_VERSION_BUILD"]
if ([string]::IsNullOrWhiteSpace($build) -or $build -eq "0") {
return $baseVersion
}
return "$baseVersion.$build"
}
function Copy-RequiredFile([string]$SourceRoot, [string]$RelativePath, [string]$DestinationRoot) {
$source = Join-Path $SourceRoot $RelativePath
if (-not (Test-Path -LiteralPath $source)) {
Fail "Required runtime file is missing: $source"
}
$destination = Join-Path $DestinationRoot $RelativePath
$destinationDirectory = Split-Path -Parent $destination
if (-not (Test-Path -LiteralPath $destinationDirectory)) {
New-Item -ItemType Directory -Path $destinationDirectory | Out-Null
}
Copy-Item -LiteralPath $source -Destination $destination -Force
}
function Copy-DirectoryIfPresent([string]$Source, [string]$Destination) {
if (-not (Test-Path -LiteralPath $Source)) {
return
}
if (-not (Test-Path -LiteralPath $Destination)) {
New-Item -ItemType Directory -Path $Destination | Out-Null
}
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) {
$hash = Get-StringHash $Value
$normalized = $normalized.Substring(0, 40) + "_" + $hash.Substring(0, 16)
}
return "$Prefix$normalized"
}
function Get-StringHash([string]$Value) {
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Value)
return -join ($md5.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") })
} finally {
$md5.Dispose()
}
}
function New-DeterministicGuid([string]$Value) {
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$bytes = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("QSfera.Desktop.WindowsInstaller|" + $Value))
$bytes[6] = ($bytes[6] -band 0x0f) -bor 0x30
$bytes[8] = ($bytes[8] -band 0x3f) -bor 0x80
return ([guid]::new($bytes)).ToString().ToUpperInvariant()
} finally {
$md5.Dispose()
}
}
function Escape-Xml([string]$Value) {
return [System.Security.SecurityElement]::Escape($Value)
}
function Remove-PathWithRetry([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) {
return
}
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
Remove-Item -LiteralPath $Path -Recurse -Force
return
} catch {
if ($attempt -eq 5) {
throw
}
Start-Sleep -Seconds 2
}
}
}
function Compress-ArchiveWithRetry([string]$SourcePath, [string]$DestinationPath) {
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
Compress-Archive -Path $SourcePath -DestinationPath $DestinationPath -Force
return
} catch {
if ($attempt -eq 5) {
throw
}
Start-Sleep -Seconds 2
}
}
}
function Get-StageRelativePath([string]$Path) {
$base = $script:StageDirectory.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$fullPath = [System.IO.Path]::GetFullPath($Path)
if (-not $fullPath.StartsWith($base, [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "Path is outside of the staging directory: $Path"
}
return $fullPath.Substring($base.Length).Replace('\', '/')
}
function New-WixDirectoryXml(
[string]$DirectoryPath,
[string]$DirectoryId,
[System.Collections.Generic.List[string]]$ComponentRefs
) {
$builder = [System.Text.StringBuilder]::new()
$files = Get-ChildItem -LiteralPath $DirectoryPath -File | Sort-Object Name
foreach ($file in $files) {
$relative = Get-StageRelativePath $file.FullName
$componentId = ConvertTo-WixIdentifier "C_" $relative
$fileId = ConvertTo-WixIdentifier "F_" $relative
$guid = New-DeterministicGuid $relative
[void]$ComponentRefs.Add($componentId)
[void]$builder.AppendLine(" <Component Id=`"$componentId`" Guid=`"$guid`">")
[void]$builder.AppendLine(" <File Id=`"$fileId`" Source=`"$(Escape-Xml $file.FullName)`" KeyPath=`"yes`" />")
[void]$builder.AppendLine(" </Component>")
}
$directories = Get-ChildItem -LiteralPath $DirectoryPath -Directory | Sort-Object Name
foreach ($directory in $directories) {
$relative = Get-StageRelativePath $directory.FullName
$childDirectoryId = ConvertTo-WixIdentifier "D_" $relative
[void]$builder.AppendLine(" <Directory Id=`"$childDirectoryId`" Name=`"$(Escape-Xml $directory.Name)`">")
[void]$builder.Append((New-WixDirectoryXml -DirectoryPath $directory.FullName -DirectoryId $childDirectoryId -ComponentRefs $ComponentRefs))
[void]$builder.AppendLine(" </Directory>")
}
return $builder.ToString()
}
function New-QSferaMsi([string]$StageDirectory, [string]$MsiPath, [string]$Version) {
$wix = Get-Command wix -ErrorAction SilentlyContinue
if (-not $wix) {
Fail "wix was not found in PATH. Install WiX Toolset 6 with: dotnet tool install --global wix --version 6.*"
}
$script:StageDirectory = (Resolve-Path -LiteralPath $StageDirectory).Path
$componentRefs = [System.Collections.Generic.List[string]]::new()
$directoryXml = New-WixDirectoryXml -DirectoryPath $script:StageDirectory -DirectoryId "INSTALLFOLDER" -ComponentRefs $componentRefs
$featureRefs = ($componentRefs | ForEach-Object { " <ComponentRef Id=`"$_`" />" }) -join [Environment]::NewLine
$shortcutGuid = New-DeterministicGuid "ApplicationShortcut"
$wxsPath = [System.IO.Path]::ChangeExtension($MsiPath, ".wxs")
$upgradeCode = "8F2F0633-98AB-4E76-B9E7-3F42F36B5E5A"
$wxs = @"
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="QSfera Desktop" Manufacturer="QSfera" Version="$Version" UpgradeCode="$upgradeCode" Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version of QSfera Desktop is already installed." />
<MediaTemplate EmbedCab="yes" />
<Feature Id="MainFeature" Title="QSfera Desktop" Level="1">
$featureRefs
<ComponentRef Id="ApplicationShortcut" />
</Feature>
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="QSfera">
$directoryXml
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="QSfera">
<Component Id="ApplicationShortcut" Guid="$shortcutGuid">
<Shortcut Id="ApplicationStartMenuShortcut" Name="QSfera" Target="[INSTALLFOLDER]qsfera.exe" WorkingDirectory="INSTALLFOLDER" Description="QSfera Desktop" />
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall" />
<RegistryValue Root="HKCU" Key="Software\QSfera\Desktop" Name="StartMenuShortcut" Type="integer" Value="1" KeyPath="yes" />
</Component>
</Directory>
</StandardDirectory>
</Package>
</Wix>
"@
$wxs | Set-Content -LiteralPath $wxsPath -Encoding UTF8
& $wix.Source build $wxsPath -o $MsiPath
if ($LASTEXITCODE -ne 0) {
Fail "WiX MSI build failed with exit code $LASTEXITCODE."
}
}
$runtimeRoot = (Resolve-Path -LiteralPath $RuntimeRoot).Path
$runtimeBin = Join-Path $runtimeRoot "bin"
$runtimePlugins = Join-Path $runtimeRoot "plugins"
$runtimeQml = Join-Path $runtimeRoot "qml"
if (-not (Test-Path -LiteralPath $runtimeBin)) {
Fail "Runtime bin directory was not found: $runtimeBin"
}
if ([string]::IsNullOrWhiteSpace($Version)) {
$Version = Get-QSferaDesktopVersion
}
$OutputRoot = (New-Item -ItemType Directory -Path $OutputRoot -Force).FullName
$stageName = "QSfera-Desktop-$Version-win-x64"
$StageDirectory = Join-Path $OutputRoot $stageName
$zipPath = Join-Path $OutputRoot "$stageName.zip"
$msiPath = Join-Path $OutputRoot "$stageName.msi"
Remove-PathWithRetry $StageDirectory
New-Item -ItemType Directory -Path $StageDirectory | Out-Null
$requiredRuntimeFiles = @(
"qsfera.exe",
"qsferacmd.exe",
"QSferaGui.dll",
"QSferaLibSync.dll",
"QSferaResources.dll",
"b2-1.dll",
"brotlicommon.dll",
"brotlidec.dll",
"bz2.dll",
"kdsingleapplication-qt6.dll",
"qt6keychain.dll",
"concrt140.dll",
"freetype.dll",
"harfbuzz.dll",
"libsqlite.dll",
"libpng16.dll",
"msvcp140.dll",
"msvcp140_1.dll",
"msvcp140_2.dll",
"msvcp140_atomic_wait.dll",
"pcre2-16.dll",
"libcrypto-3-x64.dll",
"libssl-3-x64.dll",
"vcomp140.dll",
"vcruntime140.dll",
"vcruntime140_1.dll",
"vcruntime140_threads.dll",
"zlib1.dll",
"zstd.dll",
"snoretoast.exe"
)
foreach ($file in $requiredRuntimeFiles) {
Copy-RequiredFile -SourceRoot $runtimeBin -RelativePath $file -DestinationRoot $StageDirectory
}
$windeployqt = Join-Path $runtimeBin "windeployqt.exe"
if (-not (Test-Path -LiteralPath $windeployqt)) {
Fail "windeployqt was not found: $windeployqt"
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$qmlSource = Join-Path $repoRoot "Desktop\src\gui"
& $windeployqt `
--release `
--compiler-runtime `
--force `
--qmldir $qmlSource `
--qml-deploy-dir (Join-Path $StageDirectory "qml") `
--plugindir (Join-Path $StageDirectory "plugins") `
--translationdir (Join-Path $StageDirectory "translations") `
(Join-Path $StageDirectory "qsfera.exe") `
(Join-Path $StageDirectory "QSferaGui.dll") `
(Join-Path $StageDirectory "QSferaLibSync.dll") `
(Join-Path $StageDirectory "QSferaResources.dll")
if ($LASTEXITCODE -ne 0) {
Fail "windeployqt failed with exit code $LASTEXITCODE."
}
Copy-RequiredFile -SourceRoot $runtimePlugins -RelativePath "QSfera_vfs_cfapi.dll" -DestinationRoot (Join-Path $StageDirectory "plugins")
Copy-RequiredFile -SourceRoot $runtimePlugins -RelativePath "QSfera_vfs_off.dll" -DestinationRoot (Join-Path $StageDirectory "plugins")
Copy-DirectoryIfPresent -Source (Join-Path $runtimeQml "eu\QSfera") -Destination (Join-Path $StageDirectory "qml\eu\QSfera")
@"
[Paths]
Prefix=.
Plugins=plugins
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
if (-not $SkipMsi) {
Remove-PathWithRetry $msiPath
New-QSferaMsi -StageDirectory $StageDirectory -MsiPath $msiPath -Version $Version
}
$stageFiles = Get-ChildItem -LiteralPath $StageDirectory -Recurse -File
$stageBytes = ($stageFiles | Measure-Object Length -Sum).Sum
Write-Output "QSfera Windows desktop package prepared."
Write-Output "Version: $Version"
Write-Output "Stage: $StageDirectory"
Write-Output "Stage files: $($stageFiles.Count)"
Write-Output "Stage bytes: $stageBytes"
Write-Output "ZIP: $zipPath"
if (-not $SkipMsi) {
Write-Output "MSI: $msiPath"
}
+190
View File
@@ -0,0 +1,190 @@
param(
[string]$MsiPath = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi"),
[string]$Version,
[string]$Channel = "stable",
[string]$PublicBaseUrl = "https://qsfera.kusoft.xyz",
[string]$OutputRoot = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop\publish"),
[string]$RemoteHost,
[string]$RemoteUser = "sevenhill",
[int]$RemotePort = 22,
[string]$RemoteDownloadDirectory = "/var/www/qsfera/desktop/downloads",
[string]$RemoteFeedPath = "/var/www/qsfera/desktop/updates/stable.xml",
[switch]$SkipSignatureCheck
)
$ErrorActionPreference = "Stop"
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Assert-HttpsBaseUrl([string]$Url) {
$uri = $null
if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne "https") {
Fail "PublicBaseUrl must be an absolute HTTPS URL: $Url"
}
return $uri.AbsoluteUri.TrimEnd("/")
}
function Get-QSferaDesktopVersion {
$versionFile = Join-Path $PSScriptRoot "..\Desktop\VERSION.cmake"
if (-not (Test-Path -LiteralPath $versionFile)) {
Fail "Desktop version file was not found: $versionFile"
}
$values = @{}
foreach ($line in Get-Content -LiteralPath $versionFile) {
if ($line -match '^\s*set\(\s*(MIRALL_VERSION_(MAJOR|MINOR|PATCH|BUILD))\s+"?([^"\s\)]+)"?\s*\)') {
$values[$matches[1]] = $matches[3]
}
}
foreach ($key in @("MIRALL_VERSION_MAJOR", "MIRALL_VERSION_MINOR", "MIRALL_VERSION_PATCH")) {
if (-not $values.ContainsKey($key)) {
Fail "Desktop version file does not define $key."
}
}
$baseVersion = "{0}.{1}.{2}" -f $values["MIRALL_VERSION_MAJOR"], $values["MIRALL_VERSION_MINOR"], $values["MIRALL_VERSION_PATCH"]
$build = $values["MIRALL_VERSION_BUILD"]
if ([string]::IsNullOrWhiteSpace($build) -or $build -eq "0") {
return $baseVersion
}
return "$baseVersion.$build"
}
function Quote-RemotePath([string]$Path) {
return "'" + ($Path -replace "'", "'\''") + "'"
}
function Get-RemoteParentPath([string]$Path) {
$index = $Path.LastIndexOf("/")
if ($index -le 0) {
return "/"
}
return $Path.Substring(0, $index)
}
function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) {
& $Program @Arguments
if ($LASTEXITCODE -ne 0) {
Fail "$Program failed with exit code $LASTEXITCODE."
}
}
$resolvedMsi = $null
try {
$resolvedMsi = (Resolve-Path -LiteralPath $MsiPath).Path
} catch {
Fail "MSI file was not found: $MsiPath"
}
if (-not $resolvedMsi.EndsWith(".msi", [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "Windows desktop publication expects an .msi file: $resolvedMsi"
}
$hasRemoteTarget = -not [string]::IsNullOrWhiteSpace($RemoteHost)
if ($hasRemoteTarget -and $SkipSignatureCheck) {
Fail "Remote Windows desktop publication cannot use -SkipSignatureCheck."
}
$signature = Get-AuthenticodeSignature -LiteralPath $resolvedMsi
if (-not $SkipSignatureCheck -and $signature.Status -ne "Valid") {
Fail "MSI must be Authenticode-signed before Windows production publication. Actual status: $($signature.Status)."
}
if ([string]::IsNullOrWhiteSpace($Version)) {
$Version = Get-QSferaDesktopVersion
}
$parsedVersion = $null
if (-not [System.Version]::TryParse($Version, [ref]$parsedVersion)) {
Fail "Version must be parseable as a Windows updater version: $Version"
}
if ($Channel -ne "stable") {
Fail "Only the stable desktop update feed is configured for production publication. Actual channel: $Channel"
}
$publicBase = Assert-HttpsBaseUrl $PublicBaseUrl
$msiName = Split-Path -Leaf $resolvedMsi
$downloadUrl = "$publicBase/desktop/downloads/$msiName"
$feedPath = Join-Path (New-Item -ItemType Directory -Path $OutputRoot -Force).FullName "$Channel.xml"
$sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedMsi).Hash.ToLowerInvariant()
$size = (Get-Item -LiteralPath $resolvedMsi).Length
$feed = @"
<?xml version="1.0" encoding="UTF-8"?>
<QSfera>
<version>$Version</version>
<versionstring>$Version</versionstring>
<web>$publicBase/</web>
<downloadurl>$downloadUrl</downloadurl>
</QSfera>
"@
$feed | Set-Content -LiteralPath $feedPath -Encoding UTF8
$feedValidator = Join-Path $PSScriptRoot "..\Desktop\scripts\validate-desktop-update-feed.ps1"
if (-not (Test-Path -LiteralPath $feedValidator)) {
Fail "Desktop update feed validator was not found: $feedValidator"
}
& $feedValidator -Path $feedPath
if (-not $?) {
Fail "Generated desktop update feed failed validation."
}
Write-Output "QSfera Windows desktop publication prepared."
Write-Output "MSI: $resolvedMsi"
Write-Output "Version: $Version"
Write-Output "Size: $size"
Write-Output "SHA-256: $sha256"
Write-Output "Feed: $feedPath"
Write-Output "Download URL: $downloadUrl"
Write-Output "Signature: $($signature.Status)"
if (-not $hasRemoteTarget) {
Write-Output "Remote upload skipped: set -RemoteHost after signing the MSI."
exit 0
}
$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 remote Windows desktop publication."
}
$remote = "$RemoteUser@$RemoteHost"
$remoteMsiTmp = "$RemoteDownloadDirectory/$msiName.tmp"
$remoteMsiFinal = "$RemoteDownloadDirectory/$msiName"
$remoteFeedTmp = "$RemoteFeedPath.tmp"
$sshPort = [string]$RemotePort
$remoteFeedDirectory = Get-RemoteParentPath $RemoteFeedPath
$prepareCommand = @(
"set -e",
"mkdir -p $(Quote-RemotePath $RemoteDownloadDirectory)",
"mkdir -p $(Quote-RemotePath $remoteFeedDirectory)"
) -join " && "
Invoke-CheckedNative $ssh.Source @("-p", $sshPort, $remote, $prepareCommand)
Invoke-CheckedNative $scp.Source @("-P", $sshPort, $resolvedMsi, "${remote}:$remoteMsiTmp")
Invoke-CheckedNative $scp.Source @("-P", $sshPort, $feedPath, "${remote}:$remoteFeedTmp")
$remoteCommand = @(
"set -e",
"test -s $(Quote-RemotePath $remoteMsiTmp)",
"test -s $(Quote-RemotePath $remoteFeedTmp)",
"mv -f $(Quote-RemotePath $remoteMsiTmp) $(Quote-RemotePath $remoteMsiFinal)",
"mv -f $(Quote-RemotePath $remoteFeedTmp) $(Quote-RemotePath $RemoteFeedPath)"
) -join " && "
Invoke-CheckedNative $ssh.Source @("-p", $sshPort, $remote, $remoteCommand)
& $feedValidator -Url "$publicBase/desktop/updates/$Channel.xml"
if (-not $?) {
Fail "Live desktop update feed failed validation after upload."
}
Write-Output "QSfera Windows desktop publication uploaded."
+69
View File
@@ -0,0 +1,69 @@
param(
[string]$RuntimeRoot = "$env:USERPROFILE\craft\CraftMaster\windows-cl-msvc2022-x86_64",
[string]$AppName = "QSfera",
[switch]$Launch
)
$ErrorActionPreference = "Stop"
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
$runtimeBin = Join-Path $RuntimeRoot "bin"
$runtimeExe = Join-Path $runtimeBin "qsfera.exe"
if (-not (Test-Path -LiteralPath $runtimeExe)) {
Fail "QSfera executable was not found: $runtimeExe"
}
$requiredDlls = @(
"Qt6Core.dll",
"Qt6Gui.dll",
"Qt6Widgets.dll",
"Qt6Network.dll",
"Qt6Qml.dll",
"Qt6Quick.dll",
"qt6keychain.dll",
"kdsingleapplication-qt6.dll"
)
foreach ($dll in $requiredDlls) {
$dllPath = Join-Path $runtimeBin $dll
if (-not (Test-Path -LiteralPath $dllPath)) {
Fail "Required runtime DLL is missing: $dllPath"
}
}
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
New-ItemProperty -Path $runKey -Name $AppName -PropertyType String -Value $runtimeExe -Force | Out-Null
$actualValue = (Get-ItemProperty -Path $runKey).$AppName
if ($actualValue -ne $runtimeExe) {
Fail "Autostart registry value was not updated. Expected '$runtimeExe', got '$actualValue'."
}
Write-Output "OK: $AppName autostart points to $runtimeExe"
Write-Output "OK: required Qt6 runtime DLLs are present in $runtimeBin"
if ($Launch) {
Get-Process -Name "qsfera" -ErrorAction SilentlyContinue |
Where-Object { $_.Path -ne $runtimeExe } |
Stop-Process -Force -ErrorAction SilentlyContinue
if (-not (Get-Process -Name "qsfera" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $runtimeExe })) {
Start-Process -FilePath $runtimeExe -WorkingDirectory $runtimeBin -WindowStyle Hidden
Start-Sleep -Seconds 5
}
$process = Get-Process -Name "qsfera" -ErrorAction SilentlyContinue |
Where-Object { $_.Path -eq $runtimeExe } |
Select-Object -First 1
if (-not $process) {
Fail "QSfera did not start from $runtimeExe"
}
Write-Output "OK: QSfera is running from $($process.Path) with PID $($process.Id)"
}
+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.7",
[int]$ExpectedAndroidVersionCode = 35,
[switch]$RequireAndroidPackageName,
[string]$ExpectedAndroidVersion = "1.3.14",
[int]$ExpectedAndroidVersionCode = 42,
[switch]$RequireAndroidVersionCode,
[string]$ExpectedAndroidChannel = "stable",
[string]$ExpectedAndroidPlatform = "android",
[string]$ExpectedAndroidPackageKind = "apk",
[string]$ExpectedAndroidSha256 = "c9c044c1332f8d973c64502ad2e3227ad92398101e7354ce9dbe7f708e7e693b",
[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."