47 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
Курнат Андрей 89f3cd0934 Add Android auto-upload mobile data limit 2026-06-09 21:54:25 +03:00
Курнат Андрей 4bf54f7fda Install Raspberry Pi production monitor timer 2026-06-09 21:26:17 +03:00
Курнат Андрей ccd20d76e7 Document Raspberry Pi metrics rollout 2026-06-09 21:17:27 +03:00
Курнат Андрей 9323f976d8 Add Raspberry Pi production verification runbook 2026-06-09 21:14:26 +03:00
Курнат Андрей 5e4dfc6642 Format desktop configfile test 2026-06-09 21:01:07 +03:00
Курнат Андрей fa47f9b32e Fix desktop image provider size fallback 2026-06-09 20:56:27 +03:00
Курнат Андрей ccc6bda044 Publish Android 1.3.7 to Argus 2026-06-08 21:56:31 +03:00
Курнат Андрей cf366b2699 Add Android backup roaming control 2026-06-08 21:50:33 +03:00
Курнат Андрей c9c4b1b17d Add production endpoint verifier 2026-06-08 21:40:09 +03:00
Курнат Андрей ac618d400b Polish client UI labels 2026-06-08 21:35:46 +03:00
Курнат Андрей 955be5134d Configure desktop update feed 2026-06-08 21:18:46 +03:00
Курнат Андрей e9aa9f8315 Pin Pi server stable image 2026-06-08 20:59:30 +03:00
Курнат Андрей 458831c83c Record Pi compose deployment 2026-06-08 20:11:16 +03:00
Курнат Андрей 43caef7a6a Prepare QSfera production release 2026-06-08 19:57:40 +03:00
104 changed files with 10060 additions and 451 deletions
+1
View File
@@ -0,0 +1 @@
artifacts/
+7
View File
@@ -12,6 +12,13 @@
# Local configuration files (sdk path, etc)
local.properties
# Local Argus release signing secrets
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.
+28 -2
View File
@@ -14,6 +14,18 @@ def envValue(String primary, String legacy = null) {
return legacy ? System.getenv(legacy) : null
}
def releaseSigningEnv = [
[primary: 'QSFERA_RELEASE_KEYSTORE', legacy: 'OC_RELEASE_KEYSTORE'],
[primary: 'QSFERA_RELEASE_KEYSTORE_PASSWORD', legacy: 'OC_RELEASE_KEYSTORE_PASSWORD'],
[primary: 'QSFERA_RELEASE_KEY_ALIAS', legacy: 'OC_RELEASE_KEY_ALIAS'],
[primary: 'QSFERA_RELEASE_KEY_PASSWORD', legacy: 'OC_RELEASE_KEY_PASSWORD'],
]
def missingReleaseSigningEnvKeys = {
releaseSigningEnv.findAll { !envValue(it.primary, it.legacy) }
.collect { "${it.primary} (${it.legacy} legacy fallback)" }
}
dependencies {
// Data and domain modules
implementation project(':qsferaDomain')
@@ -123,8 +135,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 32
versionName = "1.3.4"
versionCode = 42
versionName = "1.3.14"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -235,6 +247,20 @@ android {
testNamespace "eu.qsfera.android.test"
}
gradle.taskGraph.whenReady { taskGraph ->
def releaseBuildRequested = taskGraph.allTasks.any { task ->
def taskName = task.name.toLowerCase()
taskName.endsWith("release") &&
(taskName.startsWith("assemble") || taskName.startsWith("bundle") || taskName.startsWith("package"))
}
def missingSigningEnvKeys = missingReleaseSigningEnvKeys()
if (releaseBuildRequested && !missingSigningEnvKeys.isEmpty()) {
throw new GradleException(
"Release builds must be signed. Missing env vars: ${missingSigningEnvKeys.join(', ')}"
)
}
}
// Updates output file names of a given variant to format
// [appName].[variant.versionName].[QSFERA_BUILD_NUMBER]-[variant.name].apk.
//
@@ -48,8 +48,12 @@ public abstract class PreferenceManager {
public static final String PREF__CAMERA_VIDEO_UPLOADS_ENABLED = "enable_video_uploads";
public static final String PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "picture_uploads_on_wifi";
public static final String PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY = "picture_uploads_on_charging";
public static final String PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING = "picture_uploads_allow_roaming";
public static final String PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT = "picture_uploads_mobile_data_limit";
public static final String PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "video_uploads_on_wifi";
public static final String PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY = "video_uploads_on_charging";
public static final String PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING = "video_uploads_allow_roaming";
public static final String PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT = "video_uploads_mobile_data_limit";
public static final String PREF__CAMERA_PICTURE_UPLOADS_PATH = "picture_uploads_path";
public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_uploads_path";
public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour";
@@ -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
@@ -41,20 +42,22 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ENABLED
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT
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
@@ -65,9 +68,12 @@ 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
private var prefPictureUploadsAllowRoaming: CheckBoxPreference? = null
private var prefPictureUploadsMobileDataLimit: ListPreference? = null
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
private var prefPictureUploadsSourcePath: Preference? = null
private var prefPictureUploadsClearSourcePaths: Preference? = null
@@ -97,9 +103,12 @@ 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)
prefPictureUploadsAllowRoaming = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING)
prefPictureUploadsMobileDataLimit = findPreference(PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT)
prefPictureUploadsOnCharging = findPreference(PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY)
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
@@ -114,7 +123,9 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
prefPictureUploadsSourcePath?.title = String.format(prefPictureUploadsSourcePath?.title.toString(), comment)
prefPictureUploadsSourcePath?.let {
it.title = String.format(it.title?.toString().orEmpty(), comment)
}
initPreferenceListeners()
}
@@ -128,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()
@@ -140,7 +157,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let {
if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName
} else {
@@ -148,21 +165,41 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
}
picturesViewModel.pictureUploads.collect { pictureUploadsConfiguration ->
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
enablePictureUploads(pictureUploadsConfiguration != null, false)
pictureUploadsConfiguration?.let {
prefPictureUploadsAccount?.value = it.accountName
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
val sourcePaths = it.sourcePaths
val sourcePathLabels = getSourcePathLabels(sourcePaths)
prefPictureUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
context = requireContext(),
configuration = it,
sourcePaths = sourcePaths,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths),
userQuota = availableAccounts.firstOrNull { userQuota ->
userQuota.accountName == it.accountName
},
sourcePathLabels = sourcePathLabels,
transferSummary = transferSummary,
pauseReasons = AutomaticUploadRuntimeStatusResolver.pauseReasons(
context = requireContext(),
configuration = it,
transferSummary = transferSummary,
),
)
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
}
}
@@ -241,26 +278,40 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
picturesViewModel.useWifiOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.useWifiOnly(value)
prefPictureUploadsAllowRoaming?.isEnabled = !value
prefPictureUploadsMobileDataLimit?.isEnabled = !value
value
}
prefPictureUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.allowRoaming(value)
value
}
prefPictureUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue ->
val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false
picturesViewModel.setMobileDataLimitBytes(value)
true
}
prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
picturesViewModel.useChargingOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.useChargingOnly(value)
value
}
prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
picturesViewModel.handleSelectAccount(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
picturesViewModel.handleSelectAccount(value)
true
}
prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
picturesViewModel.handleSelectBehaviour(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
picturesViewModel.handleSelectBehaviour(value)
true
}
}
@@ -277,6 +328,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
prefPictureUploadsPath?.isEnabled = value
prefPictureUploadsOnWifi?.isEnabled = value
prefPictureUploadsAllowRoaming?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
prefPictureUploadsMobileDataLimit?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
prefPictureUploadsOnCharging?.isEnabled = value
prefPictureUploadsSourcePath?.isEnabled = value
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
@@ -286,26 +339,61 @@ 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)
prefPictureUploadsClearSourcePaths?.isEnabled = false
prefPictureUploadsOnWifi?.isChecked = false
prefPictureUploadsAllowRoaming?.isChecked = false
prefPictureUploadsMobileDataLimit?.value = "0"
prefPictureUploadsOnCharging?.isChecked = false
prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name
prefPictureUploadsLastSync?.summary = null
prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
if (sourcePaths.isEmpty()) {
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
if (sourcePathLabels.isEmpty()) {
getString(R.string.prefs_camera_upload_source_paths_empty)
} else {
sourcePaths.joinToString(separator = "\n") { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
sourcePathLabels.values.joinToString(separator = "\n")
}
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
sourcePaths.associateWith { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
private fun getUnreadableSourcePaths(sourcePaths: List<String>): Set<String> =
sourcePaths.filterTo(linkedSetOf()) { sourcePath ->
!canReadSourcePath(sourcePath)
}
private fun canReadSourcePath(sourcePath: String): Boolean =
try {
val sourceUri = sourcePath.toUri()
val hasPersistedReadPermission = requireContext().contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(requireContext(), sourceUri)
hasPersistedReadPermission && documentTree?.canRead() == true
} catch (_: RuntimeException) {
false
}
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
if (lastSyncTimestamp <= 0L) {
getString(R.string.prefs_camera_upload_last_sync_never)
} else {
DisplayUtils.unixTimeToHumanReadable(lastSyncTimestamp)
}
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,8 @@ 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
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
@@ -39,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
@@ -57,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,
@@ -67,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()
}
@@ -111,6 +133,26 @@ class SettingsPictureUploadsViewModel(
}
}
fun allowRoaming(allowRoaming: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(allowRoaming = allowRoaming)
)
)
}
}
fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes)
)
)
}
}
fun useChargingOnly(chargingOnly: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
@@ -175,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 = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
)
)
)
@@ -194,6 +245,7 @@ class SettingsPictureUploadsViewModel(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(
sourcePath = "",
sourcePathSyncTimestamps = "",
timestamp = System.currentTimeMillis()
)
)
@@ -210,8 +262,13 @@ class SettingsPictureUploadsViewModel(
accountName: String? = _pictureUploads.value?.accountName,
uploadPath: String? = _pictureUploads.value?.uploadPath,
wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly,
allowRoaming: Boolean? = _pictureUploads.value?.allowRoaming,
mobileDataLimitBytes: Long? = _pictureUploads.value?.mobileDataLimitBytes,
mobileDataPeriodStartTimestamp: Long? = _pictureUploads.value?.mobileDataPeriodStartTimestamp,
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,
@@ -220,9 +277,14 @@ class SettingsPictureUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
wifiOnly = wifiOnly != false,
allowRoaming = allowRoaming == true,
mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
name = _pictureUploads.value?.name ?: pictureUploadsName,
spaceId = spaceId,
).also {
@@ -238,7 +300,7 @@ class SettingsPictureUploadsViewModel(
fun getUploadPathString(): String {
val spaceName = handleSpaceName(pictureUploadsSpace?.name)
val spaceName = handleSpaceName(pictureUploadsSpace?.name).orEmpty()
val uploadPath = pictureUploads.value?.uploadPath
val spaceId = pictureUploads.value?.spaceId
@@ -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
@@ -42,19 +43,21 @@ import androidx.preference.SwitchPreferenceCompat
import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ENABLED
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT
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
@@ -65,9 +68,12 @@ 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
private var prefVideoUploadsAllowRoaming: CheckBoxPreference? = null
private var prefVideoUploadsMobileDataLimit: ListPreference? = null
private var prefVideoUploadsOnCharging: CheckBoxPreference? = null
private var prefVideoUploadsSourcePath: Preference? = null
private var prefVideoUploadsClearSourcePaths: Preference? = null
@@ -97,9 +103,12 @@ 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)
prefVideoUploadsAllowRoaming = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING)
prefVideoUploadsMobileDataLimit = findPreference(PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT)
prefVideoUploadsOnCharging = findPreference(PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY)
prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE)
prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS)
@@ -112,7 +121,9 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
prefVideoUploadsSourcePath?.title = String.format(prefVideoUploadsSourcePath?.title.toString(), comment)
prefVideoUploadsSourcePath?.let {
it.title = String.format(it.title?.toString().orEmpty(), comment)
}
initPreferenceListeners()
}
@@ -126,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()
@@ -138,7 +155,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let {
if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName
} else {
@@ -146,21 +163,41 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
}
videosViewModel.videoUploads.collect { videoUploadsConfiguration ->
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
enableVideoUploads(videoUploadsConfiguration != null, false)
videoUploadsConfiguration?.let {
prefVideoUploadsAccount?.value = it.accountName
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
val sourcePaths = it.sourcePaths
val sourcePathLabels = getSourcePathLabels(sourcePaths)
prefVideoUploadsStatus?.summary = AutomaticUploadStatusFormatter.format(
context = requireContext(),
configuration = it,
sourcePaths = sourcePaths,
lastSyncSummary = formatLastSyncSummary(it.lastSyncTimestamp),
unreadableSourcePaths = getUnreadableSourcePaths(sourcePaths),
userQuota = availableAccounts.firstOrNull { userQuota ->
userQuota.accountName == it.accountName
},
sourcePathLabels = sourcePathLabels,
transferSummary = transferSummary,
pauseReasons = AutomaticUploadRuntimeStatusResolver.pauseReasons(
context = requireContext(),
configuration = it,
transferSummary = transferSummary,
),
)
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePathLabels)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
}
}
@@ -239,26 +276,40 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
videosViewModel.useWifiOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.useWifiOnly(value)
prefVideoUploadsAllowRoaming?.isEnabled = !value
prefVideoUploadsMobileDataLimit?.isEnabled = !value
value
}
prefVideoUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.allowRoaming(value)
value
}
prefVideoUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue ->
val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false
videosViewModel.setMobileDataLimitBytes(value)
true
}
prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
videosViewModel.useChargingOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.useChargingOnly(value)
value
}
prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
videosViewModel.handleSelectAccount(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
videosViewModel.handleSelectAccount(value)
true
}
prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
videosViewModel.handleSelectBehaviour(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
videosViewModel.handleSelectBehaviour(value)
true
}
}
@@ -275,6 +326,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
prefVideoUploadsPath?.isEnabled = value
prefVideoUploadsOnWifi?.isEnabled = value
prefVideoUploadsAllowRoaming?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
prefVideoUploadsMobileDataLimit?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
prefVideoUploadsOnCharging?.isEnabled = value
prefVideoUploadsSourcePath?.isEnabled = value
prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty()
@@ -284,26 +337,61 @@ 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)
prefVideoUploadsClearSourcePaths?.isEnabled = false
prefVideoUploadsOnWifi?.isChecked = false
prefVideoUploadsAllowRoaming?.isChecked = false
prefVideoUploadsMobileDataLimit?.value = "0"
prefVideoUploadsOnCharging?.isChecked = false
prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name
prefVideoUploadsLastSync?.summary = null
prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
if (sourcePaths.isEmpty()) {
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
if (sourcePathLabels.isEmpty()) {
getString(R.string.prefs_camera_upload_source_paths_empty)
} else {
sourcePaths.joinToString(separator = "\n") { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
sourcePathLabels.values.joinToString(separator = "\n")
}
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
sourcePaths.associateWith { sourcePath ->
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
}
private fun getUnreadableSourcePaths(sourcePaths: List<String>): Set<String> =
sourcePaths.filterTo(linkedSetOf()) { sourcePath ->
!canReadSourcePath(sourcePath)
}
private fun canReadSourcePath(sourcePath: String): Boolean =
try {
val sourceUri = sourcePath.toUri()
val hasPersistedReadPermission = requireContext().contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(requireContext(), sourceUri)
hasPersistedReadPermission && documentTree?.canRead() == true
} catch (_: RuntimeException) {
false
}
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
if (lastSyncTimestamp <= 0L) {
getString(R.string.prefs_camera_upload_last_sync_never)
} else {
DisplayUtils.unixTimeToHumanReadable(lastSyncTimestamp)
}
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,8 @@ 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
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
@@ -39,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
@@ -57,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,
@@ -67,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()
}
@@ -111,6 +133,26 @@ class SettingsVideoUploadsViewModel(
}
}
fun allowRoaming(allowRoaming: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(allowRoaming = allowRoaming)
)
)
}
}
fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes)
)
)
}
}
fun useChargingOnly(chargingOnly: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
@@ -175,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 = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
)
)
)
@@ -194,6 +245,7 @@ class SettingsVideoUploadsViewModel(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(
sourcePath = "",
sourcePathSyncTimestamps = "",
timestamp = System.currentTimeMillis()
)
)
@@ -210,8 +262,13 @@ class SettingsVideoUploadsViewModel(
accountName: String? = _videoUploads.value?.accountName,
uploadPath: String? = _videoUploads.value?.uploadPath,
wifiOnly: Boolean? = _videoUploads.value?.wifiOnly,
allowRoaming: Boolean? = _videoUploads.value?.allowRoaming,
mobileDataLimitBytes: Long? = _videoUploads.value?.mobileDataLimitBytes,
mobileDataPeriodStartTimestamp: Long? = _videoUploads.value?.mobileDataPeriodStartTimestamp,
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,
@@ -221,9 +278,14 @@ class SettingsVideoUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
wifiOnly = wifiOnly != false,
allowRoaming = allowRoaming == true,
mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
name = _videoUploads.value?.name ?: videoUploadsName,
spaceId = spaceId,
).also {
@@ -239,7 +301,7 @@ class SettingsVideoUploadsViewModel(
fun getUploadPathString(): String {
val spaceName = handleSpaceName(videoUploadsSpace?.name)
val spaceName = handleSpaceName(videoUploadsSpace?.name).orEmpty()
val uploadPath = videoUploads.value?.uploadPath
val spaceId = videoUploads.value?.spaceId
@@ -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)
@@ -42,6 +42,9 @@ import eu.qsfera.android.workers.OldLogsCollectorWorker
import eu.qsfera.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
@@ -90,28 +93,30 @@ class WorkManagerProvider(
* concurrent scans or redundant enqueues on rapid foreground/background switches.
*/
fun enqueueImmediateAutomaticUploadsWorker() {
val wm = WorkManager.getInstance(context)
CoroutineScope(Dispatchers.IO).launch {
val wm = WorkManager.getInstance(context)
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return@launch
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
fun enqueueOldLogsCollectorWorker() {
@@ -58,7 +58,7 @@ class UploadFileFromContentUriUseCase(
.putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString())
.build()
val networkRequired = if (params.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
val networkRequired = getRequiredNetworkType(params.wifiOnly, params.allowRoaming)
val constraints = Constraints.Builder()
.setRequiredNetworkType(networkRequired)
.setRequiresCharging(params.chargingOnly)
@@ -109,6 +109,16 @@ class UploadFileFromContentUriUseCase(
val uploadPath: String,
val uploadIdInStorageManager: Long,
val wifiOnly: Boolean,
val allowRoaming: Boolean = true,
val chargingOnly: Boolean,
)
companion object {
fun getRequiredNetworkType(wifiOnly: Boolean, allowRoaming: Boolean): NetworkType =
when {
wifiOnly -> NetworkType.UNMETERED
allowRoaming -> NetworkType.CONNECTED
else -> NetworkType.NOT_ROAMING
}
}
}
@@ -22,6 +22,7 @@
package eu.qsfera.android.workers
import android.content.Context
import android.net.ConnectivityManager
import android.net.Uri
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
@@ -51,7 +52,9 @@ import org.koin.core.component.inject
import timber.log.Timber
import java.io.File
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
import java.util.concurrent.TimeUnit
class AutomaticUploadsWorker(
@@ -128,8 +131,13 @@ class AutomaticUploadsWorker(
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
sourcePaths.forEach { sourcePath ->
val sourceUri: Uri = sourcePath.toUri()
DocumentFile.fromTreeUri(applicationContext, sourceUri)
val hasPersistedReadPermission = appContext.contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
?: throw IllegalArgumentException("Source path is not a valid tree URI: $sourcePath")
if (!hasPersistedReadPermission || !documentTree.canRead()) {
throw IllegalArgumentException("Source path is not readable: $sourcePath")
}
}
}
@@ -137,9 +145,12 @@ class AutomaticUploadsWorker(
WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER)
}
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration?) {
if (folderBackUpConfiguration == null) return
private fun isActiveNetworkMetered(): Boolean {
val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
return connectivityManager?.isActiveNetworkMetered != false
}
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration) {
val syncType = when {
folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS
folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS
@@ -148,29 +159,50 @@ class AutomaticUploadsWorker(
}
val currentTimestamp = System.currentTimeMillis()
val activeNetworkMetered = isActiveNetworkMetered()
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,
)
}
showNotification(syncType, localPicturesDocumentFiles.size)
var queuedUploadsCount = 0
for (documentFile in localPicturesDocumentFiles) {
// Dedup: if this content URI already has a queued, in-progress, or succeeded transfer,
// skip it. Without this, a worker killed mid-loop (before updateTimestamp) or a
// file whose lastModified changed (e.g. media scanner) would be re-discovered and
// enqueued with a new upload ID leading to duplicate uploads or 0-byte files
// enqueued with a new upload ID - leading to duplicate uploads or 0-byte files
// when two workers race on the same cache path.
val contentUri = documentFile.uri.toString()
if (transferRepository.existsNonFailedTransferForUri(contentUri)) {
Timber.d("Skipping already-tracked file: %s", documentFile.name)
continue
}
val documentFileSize = documentFile.length()
if (!canQueueMobileDataUpload(
wifiOnly = folderBackUpConfiguration.wifiOnly,
isActiveNetworkMetered = activeNetworkMetered,
mobileDataLimitBytes = folderBackUpConfiguration.mobileDataLimitBytes,
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod,
fileSize = documentFileSize
)
) {
mobileDataLimitReached = true
Timber.i(
"Skipping automatic upload %s because the daily mobile data queue limit has been reached",
documentFile.name
)
continue
}
val uploadId = storeInUploadsDatabase(
documentFile = documentFile,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
@@ -190,13 +222,42 @@ class AutomaticUploadsWorker(
accountName = folderBackUpConfiguration.accountName,
uploadId = uploadId,
wifiOnly = folderBackUpConfiguration.wifiOnly,
allowRoaming = folderBackUpConfiguration.allowRoaming,
chargingOnly = folderBackUpConfiguration.chargingOnly
)
if (shouldCountAgainstMobileDataLimit(
wifiOnly = folderBackUpConfiguration.wifiOnly,
isActiveNetworkMetered = activeNetworkMetered,
mobileDataLimitBytes = folderBackUpConfiguration.mobileDataLimitBytes,
)
) {
mobileDataQuotaState = mobileDataQuotaState.copy(
bytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod + documentFileSize
)
}
queuedUploadsCount += 1
}
showNotification(syncType, queuedUploadsCount)
// Save safeTimestamp (not currentTimestamp) so that files skipped by the
// write-safety buffer are re-evaluated on the next run instead of being lost.
// Keep the previous timestamp when the mobile-data daily limit stops the scan,
// so skipped files are re-evaluated after the quota period resets.
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp)
val nextTimestamp = if (mobileDataLimitReached) folderBackUpConfiguration.lastSyncTimestamp else safeTimestamp
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(
@@ -245,23 +306,31 @@ class AutomaticUploadsWorker(
)
}
private fun updateTimestamp(
private fun updateConfigurationAfterScan(
folderBackUpConfiguration: FolderBackUpConfiguration,
syncType: SyncType,
currentTimestamp: Long,
nextSyncTimestamp: Long,
nextSourceSyncTimestamps: Map<String, Long>,
mobileDataQuotaState: MobileDataQuotaState,
) {
val updatedConfiguration = folderBackUpConfiguration.copy(
lastSyncTimestamp = nextSyncTimestamp,
sourcePathSyncTimestamps = FolderBackUpConfiguration.encodeSourcePathSyncTimestamps(nextSourceSyncTimestamps),
mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod
)
when (syncType) {
SyncType.PICTURE_UPLOADS -> {
val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject()
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp))
SavePictureUploadsConfigurationUseCase.Params(updatedConfiguration)
)
}
SyncType.VIDEO_UPLOADS -> {
val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject()
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp))
SaveVideoUploadsConfigurationUseCase.Params(updatedConfiguration)
)
}
}
@@ -275,7 +344,7 @@ class AutomaticUploadsWorker(
): List<DocumentFile> {
val sourceUri: Uri = sourcePath.toUri()
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
val arrayOfLocalFiles = documentTree?.listFiles() ?: arrayOf()
val arrayOfLocalFiles = documentTree?.listFiles().orEmpty()
// Exclude files modified within the last few seconds. Camera apps may still be
// writing the file (not all apps use atomic rename), so picking it up too early
@@ -290,7 +359,7 @@ class AutomaticUploadsWorker(
Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path}")
Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path.orEmpty()}")
Timber.i("${filteredList.size} files are ${syncType.name} and were taken after last sync")
return filteredList
@@ -304,6 +373,7 @@ class AutomaticUploadsWorker(
accountName: String,
uploadId: Long,
wifiOnly: Boolean,
allowRoaming: Boolean,
chargingOnly: Boolean
) {
val lastModifiedInSeconds = (lastModified / 1000L).toString()
@@ -317,6 +387,7 @@ class AutomaticUploadsWorker(
uploadPath = uploadPath,
uploadIdInStorageManager = uploadId,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
chargingOnly = chargingOnly
)
)
@@ -347,6 +418,11 @@ class AutomaticUploadsWorker(
}
companion object {
data class MobileDataQuotaState(
val periodStartTimestamp: Long,
val bytesQueuedInPeriod: Long,
)
const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER"
const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER"
const val MEDIA_STORE_UPLOADS_WORKER = "MEDIA_STORE_AUTOMATIC_UPLOADS_WORKER"
@@ -357,5 +433,66 @@ class AutomaticUploadsWorker(
private const val videoUploadsNotificationId = 102
const val WRITE_SAFETY_BUFFER_MS = 10_000L
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L
fun resolveMobileDataQuotaState(
folderBackUpConfiguration: FolderBackUpConfiguration,
currentTimestamp: Long,
timeZone: TimeZone = TimeZone.getDefault(),
): MobileDataQuotaState {
val currentPeriodStart = getMobileDataLimitPeriodStart(currentTimestamp, timeZone)
val bytesQueuedInPeriod =
if (folderBackUpConfiguration.mobileDataPeriodStartTimestamp == currentPeriodStart) {
folderBackUpConfiguration.mobileDataBytesQueuedInPeriod
} else {
0L
}
return MobileDataQuotaState(
periodStartTimestamp = currentPeriodStart,
bytesQueuedInPeriod = bytesQueuedInPeriod,
)
}
fun getMobileDataLimitPeriodStart(timestamp: Long, timeZone: TimeZone = TimeZone.getDefault()): Long =
Calendar.getInstance(timeZone).apply {
timeInMillis = timestamp
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
fun shouldCountAgainstMobileDataLimit(
wifiOnly: Boolean,
isActiveNetworkMetered: Boolean,
mobileDataLimitBytes: Long,
): Boolean =
!wifiOnly && isActiveNetworkMetered && mobileDataLimitBytes > 0
fun canQueueMobileDataUpload(
wifiOnly: Boolean,
isActiveNetworkMetered: Boolean,
mobileDataLimitBytes: Long,
mobileDataBytesQueuedInPeriod: Long,
fileSize: Long,
): 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,11 +33,28 @@
android:layout_marginStart="@dimen/standard_half_margin"
android:layout_marginTop="@dimen/standard_half_margin"
android:layout_weight="1"
android:ellipsize="middle"
android:singleLine="true"
android:text="@string/username"
android:textColor="@color/textColor"
android:textSize="@dimen/two_line_primary_text_size" />
android:orientation="vertical">
<TextView
android:id="@+id/publicLinkName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:singleLine="true"
android:text="@string/username"
android:textColor="@color/textColor"
android:textSize="@dimen/two_line_primary_text_size" />
<TextView
android:id="@+id/publicLinkDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/list_item_lastmod_and_filesize_text"
android:textSize="@dimen/two_line_secondary_text_size" />
</LinearLayout>
<ImageButton
android:id="@+id/getPublicLinkButton"
@@ -60,23 +60,23 @@
<string name="prefs_touches_with_other_visible_windows_summary">Разрешает взаимодействие, когда есть перекрытие другим видимым или прозрачным окном. Это нужно, если вы используете приложения для фильтрации света от экрана. Перекрывающее приложение сможет получать информацию о взаимодействии.</string>
<string name="confirmation_touches_with_other_windows_title">Вы уверены, что хотите включить эту функцию?</string>
<string name="confirmation_touches_with_other_windows_message">Используйте эту функцию на свой страх и риск. Вредоносное приложение может попытаться обманом заставить вас выполнить какие-либо действия, используя другие представления.</string>
<string name="prefs_subsection_picture_uploads">Автоматическая загрузка изображений</string>
<string name="prefs_subsection_picture_uploads_summary">Управляйте местоположением и поведением автоматически загружаемых фотографий</string>
<string name="prefs_subsection_video_uploads">Автоматическая загрузка видео</string>
<string name="prefs_subsection_video_uploads_summary">Настройка размещения и поведения видеороликов, загружаемых автоматически</string>
<string name="prefs_camera_picture_upload">Загрузка изображений</string>
<string name="prefs_camera_picture_upload_summary">Автоматически загружать изображения, сделанные камерой</string>
<string name="prefs_camera_picture_upload_path_title">Путь загрузки изображений</string>
<string name="prefs_camera_picture_upload_conditions_title">Условия для загрузки</string>
<string name="prefs_camera_picture_upload_conditions_summary">Автозагрузка будет запущена только при удовлетворении выбранных ниже условий</string>
<string name="prefs_camera_picture_upload_on_wifi">Загрузка изображений только через wifi</string>
<string name="prefs_camera_picture_upload_on_charging">Загрузка изображений только во время зарядки</string>
<string name="prefs_subsection_picture_uploads">Резервное копирование фото</string>
<string name="prefs_subsection_picture_uploads_summary">Выберите папки телефона и условия загрузки</string>
<string name="prefs_subsection_video_uploads">Резервное копирование видео</string>
<string name="prefs_subsection_video_uploads_summary">Выберите папки с видео и условия загрузки</string>
<string name="prefs_camera_picture_upload">Копировать фото</string>
<string name="prefs_camera_picture_upload_summary">Автоматически копировать фото из выбранных папок телефона</string>
<string name="prefs_camera_picture_upload_path_title">Папка назначения в КуСфера</string>
<string name="prefs_camera_picture_upload_conditions_title">Условия загрузки</string>
<string name="prefs_camera_picture_upload_conditions_summary">Копирование запускается только при выполнении выбранных условий</string>
<string name="prefs_camera_picture_upload_on_wifi">Копировать фото только по Wi-Fi</string>
<string name="prefs_camera_picture_upload_on_charging">Копировать фото только во время зарядки</string>
<string name="prefs_camera_upload_on_charging_summary">Чтобы включить этот параметр, сначала включите загрузку только по Wi-Fi</string>
<string name="prefs_camera_video_upload">Загрузка видео</string>
<string name="prefs_camera_video_upload_summary">Автоматически загружать видео, записанные камерой</string>
<string name="prefs_camera_video_upload_path_title">Путь загрузки видео</string>
<string name="prefs_camera_video_upload_on_wifi">Загрузка видео только через wifi</string>
<string name="prefs_camera_video_upload_on_charging">Загрузка видео только во время зарядки</string>
<string name="prefs_camera_video_upload">Копировать видео</string>
<string name="prefs_camera_video_upload_summary">Автоматически копировать видео из выбранных папок телефона</string>
<string name="prefs_camera_video_upload_path_title">Папка назначения в КуСфера</string>
<string name="prefs_camera_video_upload_on_wifi">Копировать видео только по Wi-Fi</string>
<string name="prefs_camera_video_upload_on_charging">Копировать видео только во время зарядки</string>
<string name="prefs_automatic_uploads_not_available_users_light">Автоматические загрузки недоступны для пользователей Light</string>
<string name="prefs_subsection_advanced">Дополнительно</string>
<string name="prefs_subsection_notifications">Управление уведомлениями</string>
@@ -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>
@@ -553,17 +555,50 @@
<string name="file_list__footer__files">%1$d файлов</string>
<string name="file_list__footer__files_and_folder">%1$d файлов, 1 каталог</string>
<string name="file_list__footer__files_and_folders">%1$d файлов, %2$d каталогов</string>
<string name="prefs_picture_upload_account">Учётная запись для закачки изображений</string>
<string name="prefs_video_upload_account">Учётная запись для закачки видео</string>
<string name="prefs_camera_upload_source_path_title">Каталог камеры (%1$s)</string>
<string name="prefs_picture_upload_account">Учётная запись для фото</string>
<string name="prefs_video_upload_account">Учётная запись для видео</string>
<string name="prefs_camera_upload_source_path_title">Папки телефона (%1$s)</string>
<string name="prefs_camera_upload_source_path_title_required">обязательно</string>
<string name="prefs_camera_upload_source_paths_clear_title">Очистить выбранные папки</string>
<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_behaviour_dialog_title">Исходный файл будет</string>
<string name="prefs_camera_upload_behaviour_title">Исходный файл будет</string>
<string name="prefs_camera_upload_last_sync_title">Последняя синхронизация</string>
<string name="prefs_camera_upload_source_paths_clear_title">Убрать выбранные папки</string>
<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>
<string name="prefs_camera_upload_behaviour_title">Исходный файл после копирования</string>
<string name="prefs_camera_upload_last_sync_title">Последняя проверка</string>
<string name="prefs_camera_upload_last_sync_never">Ожидает первой проверки</string>
<string name="notifications_permission_denied">Вы можете обновить свои установки в Настройки</string>
<string name="upload_copy_files">Скопировать файл</string>
<string name="upload_move_files">Переместить файл</string>
@@ -589,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>
@@ -61,23 +61,24 @@
<string name="prefs_touches_with_other_visible_windows_summary">Allow touches when the view is obscured by another visible window. Enable it to use light filtering apps.</string>
<string name="confirmation_touches_with_other_windows_title">Are you sure you want to enable this feature?</string>
<string name="confirmation_touches_with_other_windows_message">Use this feature at your own risk. A malicious application could try to spoof you into unknowingly performing some actions, using other views.</string>
<string name="prefs_subsection_picture_uploads">Automatic picture uploads</string>
<string name="prefs_subsection_picture_uploads_summary">Manage location and behavior of the pictures uploaded automatically</string>
<string name="prefs_subsection_video_uploads">Automatic video uploads</string>
<string name="prefs_subsection_video_uploads_summary">Manage location and behavior of the videos uploaded automatically</string>
<string name="prefs_camera_picture_upload">Picture uploads</string>
<string name="prefs_camera_picture_upload_summary">Automatically upload pictures taken by camera</string>
<string name="prefs_camera_picture_upload_path_title">Picture upload path</string>
<string name="prefs_camera_picture_upload_conditions_title">Conditions to execute uploads</string>
<string name="prefs_camera_picture_upload_conditions_summary">Uploads will be triggered when all selected options are fulfilled</string>
<string name="prefs_camera_picture_upload_on_wifi">Upload pictures via wifi only</string>
<string name="prefs_camera_picture_upload_on_charging">Upload pictures only when charging</string>
<string name="prefs_subsection_picture_uploads">Photo backup</string>
<string name="prefs_subsection_picture_uploads_summary">Choose which phone folders are backed up and when uploads run</string>
<string name="prefs_subsection_video_uploads">Video backup</string>
<string name="prefs_subsection_video_uploads_summary">Choose which video folders are backed up and when uploads run</string>
<string name="prefs_camera_picture_upload">Back up photos</string>
<string name="prefs_camera_picture_upload_summary">Automatically back up photos from selected phone folders</string>
<string name="prefs_camera_picture_upload_path_title">QSfera destination folder</string>
<string name="prefs_camera_picture_upload_conditions_title">Upload conditions</string>
<string name="prefs_camera_picture_upload_conditions_summary">Backup runs only when the selected conditions are met</string>
<string name="prefs_camera_picture_upload_on_wifi">Use Wi-Fi only for photo backup</string>
<string name="prefs_camera_picture_upload_on_charging">Back up photos only while charging</string>
<string name="prefs_camera_upload_on_charging_summary">Enable wifi only uploads to activate this option</string>
<string name="prefs_camera_video_upload">Video uploads</string>
<string name="prefs_camera_video_upload_summary">Automatically upload videos recorded by camera</string>
<string name="prefs_camera_video_upload_path_title">Video upload path</string>
<string name="prefs_camera_video_upload_on_wifi">Upload videos via wifi only</string>
<string name="prefs_camera_video_upload_on_charging">Upload videos only when charging</string>
<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">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>
<string name="prefs_subsection_notifications">Manage notifications</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>
@@ -591,17 +594,64 @@
<string name="file_list__footer__files">%1$d files</string>
<string name="file_list__footer__files_and_folder">%1$d files, 1 folder</string>
<string name="file_list__footer__files_and_folders">%1$d files, %2$d folders</string>
<string name="prefs_picture_upload_account">Account to upload pictures</string>
<string name="prefs_video_upload_account">Account to upload videos</string>
<string name="prefs_camera_upload_source_path_title">Camera folder (%1$s)</string>
<string name="prefs_picture_upload_account">QSfera account for photos</string>
<string name="prefs_video_upload_account">QSfera account for videos</string>
<string name="prefs_camera_upload_source_path_title">Phone folders (%1$s)</string>
<string name="prefs_camera_upload_source_path_title_required">required</string>
<string name="prefs_camera_upload_source_paths_clear_title">Clear selected folders</string>
<string name="prefs_camera_upload_source_paths_empty">No folders selected</string>
<string name="confirmation_clear_camera_upload_sources_title">Clear selected folders</string>
<string name="confirmation_clear_camera_upload_sources_message">Automatic uploads will stop scanning phone folders until you add a folder again.</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Original file will be</string>
<string name="prefs_camera_upload_behaviour_title">Original file will be</string>
<string name="prefs_camera_upload_last_sync_title">Last synchronization</string>
<string name="prefs_camera_upload_source_paths_clear_title">Remove selected folders</string>
<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>
<string name="prefs_camera_upload_mobile_data_limit_summary">Only applies when Wi-Fi-only backup is off and the active network is metered</string>
<string-array name="prefs_camera_upload_mobile_data_limit_entries">
<item>Unlimited</item>
<item>10 MB per day</item>
<item>50 MB per day</item>
<item>200 MB per day</item>
<item>1 GB per day</item>
</string-array>
<string-array name="prefs_camera_upload_mobile_data_limit_values">
<item>0</item>
<item>10485760</item>
<item>52428800</item>
<item>209715200</item>
<item>1073741824</item>
</string-array>
<string name="prefs_camera_upload_behaviour_dialog_title">Original file after backup</string>
<string name="prefs_camera_upload_behaviour_title">Original file after backup</string>
<string name="prefs_camera_upload_last_sync_title">Last backup scan</string>
<string name="prefs_camera_upload_last_sync_never">Waiting for first backup scan</string>
<string name="notifications_permission_denied">You can update your preferences in Settings</string>
<string name="upload_copy_files">Copy file</string>
<string name="upload_move_files">Move file</string>
@@ -629,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"
@@ -64,6 +70,20 @@
app:iconSpaceReserved="false"
app:key="picture_uploads_on_wifi"
app:title="@string/prefs_camera_picture_upload_on_wifi" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="picture_uploads_allow_roaming"
app:summary="@string/prefs_camera_upload_allow_roaming_summary"
app:title="@string/prefs_camera_upload_allow_roaming" />
<ListPreference
app:entries="@array/prefs_camera_upload_mobile_data_limit_entries"
app:entryValues="@array/prefs_camera_upload_mobile_data_limit_values"
app:iconSpaceReserved="false"
app:key="picture_uploads_mobile_data_limit"
app:negativeButtonText=""
app:summary="@string/prefs_camera_upload_mobile_data_limit_summary"
app:title="@string/prefs_camera_upload_mobile_data_limit_title"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="picture_uploads_on_charging"
@@ -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,7 +67,22 @@
<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"
app:key="video_uploads_allow_roaming"
app:summary="@string/prefs_camera_upload_allow_roaming_summary"
app:title="@string/prefs_camera_upload_allow_roaming" />
<ListPreference
app:entries="@array/prefs_camera_upload_mobile_data_limit_entries"
app:entryValues="@array/prefs_camera_upload_mobile_data_limit_values"
app:iconSpaceReserved="false"
app:key="video_uploads_mobile_data_limit"
app:negativeButtonText=""
app:summary="@string/prefs_camera_upload_mobile_data_limit_summary"
app:title="@string/prefs_camera_upload_mobile_data_limit_title"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="video_uploads_on_charging"
@@ -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)
}
}
@@ -0,0 +1,38 @@
package eu.qsfera.android.usecases.transfers.uploads
import androidx.work.NetworkType
import org.junit.Assert.assertEquals
import org.junit.Test
class UploadFileFromContentUriUseCaseTest {
@Test
fun `network type is unmetered when wifi only is enabled`() {
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
wifiOnly = true,
allowRoaming = false
)
assertEquals(NetworkType.UNMETERED, networkType)
}
@Test
fun `network type is connected when mobile data and roaming are allowed`() {
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
wifiOnly = false,
allowRoaming = true
)
assertEquals(NetworkType.CONNECTED, networkType)
}
@Test
fun `network type avoids roaming when mobile data is allowed without roaming`() {
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
wifiOnly = false,
allowRoaming = false
)
assertEquals(NetworkType.NOT_ROAMING, networkType)
}
}
@@ -0,0 +1,185 @@
package eu.qsfera.android.workers
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.TimeZone
class AutomaticUploadsWorkerTest {
@Test
fun `mobile data quota period starts at local midnight`() {
val timeZone = TimeZone.getTimeZone("UTC")
val timestamp = 1_717_411_245_000L // 2024-06-03T10:40:45Z
val periodStart = AutomaticUploadsWorker.getMobileDataLimitPeriodStart(timestamp, timeZone)
assertEquals(1_717_372_800_000L, periodStart) // 2024-06-03T00:00:00Z
}
@Test
fun `mobile data quota resets when period changes`() {
val timeZone = TimeZone.getTimeZone("UTC")
val configuration = backupConfiguration(
mobileDataPeriodStartTimestamp = 1_717_286_400_000L,
mobileDataBytesQueuedInPeriod = 10_000L
)
val state = AutomaticUploadsWorker.resolveMobileDataQuotaState(
folderBackUpConfiguration = configuration,
currentTimestamp = 1_717_372_800_000L,
timeZone = timeZone
)
assertEquals(1_717_372_800_000L, state.periodStartTimestamp)
assertEquals(0L, state.bytesQueuedInPeriod)
}
@Test
fun `mobile data quota keeps bytes when period matches`() {
val periodStart = 1_717_372_800_000L
val configuration = backupConfiguration(
mobileDataPeriodStartTimestamp = periodStart,
mobileDataBytesQueuedInPeriod = 10_000L
)
val state = AutomaticUploadsWorker.resolveMobileDataQuotaState(
folderBackUpConfiguration = configuration,
currentTimestamp = periodStart + 60_000L,
timeZone = TimeZone.getTimeZone("UTC")
)
assertEquals(periodStart, state.periodStartTimestamp)
assertEquals(10_000L, state.bytesQueuedInPeriod)
}
@Test
fun `wifi only uploads ignore mobile data quota`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = true,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 10L,
mobileDataBytesQueuedInPeriod = 10L,
fileSize = 1_000L
)
)
}
@Test
fun `unlimited mobile data quota allows upload`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 0L,
mobileDataBytesQueuedInPeriod = Long.MAX_VALUE,
fileSize = 1_000L
)
)
}
@Test
fun `unmetered active network ignores mobile data quota`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = false,
mobileDataLimitBytes = 10L,
mobileDataBytesQueuedInPeriod = 10L,
fileSize = 1_000L
)
)
}
@Test
fun `limited mobile data quota blocks upload that would exceed limit`() {
assertFalse(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 1_000L,
mobileDataBytesQueuedInPeriod = 800L,
fileSize = 201L
)
)
}
@Test
fun `limited mobile data quota allows upload at exact limit`() {
assertTrue(
AutomaticUploadsWorker.canQueueMobileDataUpload(
wifiOnly = false,
isActiveNetworkMetered = true,
mobileDataLimitBytes = 1_000L,
mobileDataBytesQueuedInPeriod = 800L,
fileSize = 200L
)
)
}
@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,
): FolderBackUpConfiguration =
FolderBackUpConfiguration(
accountName = "user@example.com",
behavior = UploadBehavior.COPY,
sourcePath = "content://source",
uploadPath = "/CameraUpload",
wifiOnly = false,
allowRoaming = false,
mobileDataLimitBytes = 100_000L,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = false,
lastSyncTimestamp = 0L,
sourcePathSyncTimestamps = "",
name = FolderBackUpConfiguration.pictureUploadsName,
spaceId = null,
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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 = 49;
public static final int DB_VERSION = 52;
private ProviderMeta() {
}
@@ -52,6 +52,9 @@ import eu.qsfera.android.data.migrations.MIGRATION_41_42
import eu.qsfera.android.data.migrations.MIGRATION_42_43
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
@@ -127,7 +130,10 @@ abstract class QSferaDatabase : RoomDatabase() {
MIGRATION_41_42,
MIGRATION_42_43,
MIGRATION_47_48,
MIGRATION_48_49)
MIGRATION_48_49,
MIGRATION_49_50,
MIGRATION_50_51,
MIGRATION_51_52)
.build()
INSTANCE = instance
instance
@@ -68,9 +68,14 @@ class OCLocalFolderBackupDataSource(
sourcePath = sourcePath,
uploadPath = uploadPath,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly,
name = name,
lastSyncTimestamp = lastSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
spaceId = spaceId,
)
@@ -83,8 +88,13 @@ class OCLocalFolderBackupDataSource(
sourcePath = sourcePath,
uploadPath = uploadPath,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly,
lastSyncTimestamp = lastSyncTimestamp,
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
name = name,
spaceId = spaceId,
)
@@ -29,9 +29,14 @@ data class FolderBackUpEntity(
val sourcePath: String,
val uploadPath: String,
val wifiOnly: Boolean,
val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean,
val name: String,
val lastSyncTimestamp: Long,
val sourcePathSyncTimestamps: String,
val spaceId: String?,
) {
@PrimaryKey(autoGenerate = true) var id: Int = 0
@@ -48,13 +48,18 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_ENABLED, false)) return null
return FolderBackUpConfiguration(
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME, null) ?: "",
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME, null).orEmpty(),
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY, false),
uploadPath = getUploadPathForPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR),
lastSyncTimestamp = timestamp,
sourcePathSyncTimestamps = "",
name = pictureUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false,
spaceId = null,
)
@@ -64,13 +69,18 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_ENABLED, false)) return null
return FolderBackUpConfiguration(
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME, null) ?: "",
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME, null).orEmpty(),
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY, false),
uploadPath = getUploadPathForPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR),
lastSyncTimestamp = timestamp,
sourcePathSyncTimestamps = "",
name = videoUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false,
spaceId = null,
)
@@ -85,7 +95,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
}
private fun getSourcePathForPreference(keyPreference: String): String =
sharedPreferencesProvider.getString(keyPreference, null) ?: ""
sharedPreferencesProvider.getString(keyPreference, null).orEmpty()
private fun getBehaviorForPreference(keyPreference: String): UploadBehavior {
val storedBehaviour = sharedPreferencesProvider.getString(keyPreference, null) ?: return UploadBehavior.COPY
@@ -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_49_50 = object : Migration(49, 50) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN allowRoaming INTEGER NOT NULL DEFAULT '0'")
}
}
@@ -0,0 +1,13 @@
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_50_51 = object : Migration(50, 51) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataLimitBytes INTEGER NOT NULL DEFAULT '0'")
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataPeriodStartTimestamp INTEGER NOT NULL DEFAULT '0'")
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataBytesQueuedInPeriod INTEGER NOT NULL DEFAULT '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,14 +19,22 @@
package eu.qsfera.android.domain.automaticuploads.model
import java.net.URLDecoder
import java.net.URLEncoder
data class FolderBackUpConfiguration(
val accountName: String,
val behavior: UploadBehavior,
val sourcePath: String,
val uploadPath: String,
val wifiOnly: Boolean,
val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean,
val lastSyncTimestamp: Long,
val sourcePathSyncTimestamps: String,
val name: String,
val spaceId: String?,
) {
@@ -34,10 +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
@@ -53,7 +66,48 @@ data class FolderBackUpConfiguration(
.distinct()
.joinToString(SOURCE_PATH_SEPARATOR)
private const val SOURCE_PATH_SEPARATOR = "\n"
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)
}
}
@@ -31,8 +31,13 @@ val OC_BACKUP = FolderBackUpConfiguration(
sourcePath = "/Photos",
uploadPath = "/Photos",
wifiOnly = true,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true,
lastSyncTimestamp = 1542628397,
sourcePathSyncTimestamps = "",
name = "",
spaceId = null,
)
@@ -43,8 +48,13 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity(
sourcePath = "/Photos",
uploadPath = "/Photos",
wifiOnly = true,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true,
lastSyncTimestamp = 1542628397,
sourcePathSyncTimestamps = "",
name = "",
spaceId = null,
)
@@ -0,0 +1,96 @@
param(
[string]$KeystorePath = 'signing/argus-upload.jks',
[string]$PropertiesPath = 'argus-signing.local.properties',
[string]$KeyAlias = 'argus-upload',
[switch]$Force
)
$ErrorActionPreference = 'Stop'
function New-Secret {
$value = ((1..3 | ForEach-Object { [Guid]::NewGuid().ToString('N') }) -join '')
return $value.Substring(0, 32)
}
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 $keytool) {
return $candidate
}
}
throw 'keytool.exe was not found. Set JAVA_HOME to a JDK path.'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedKeystorePath = if ([System.IO.Path]::IsPathRooted($KeystorePath)) {
$KeystorePath
} else {
Join-Path $repoRoot $KeystorePath
}
$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) {
$PropertiesPath
} else {
Join-Path $repoRoot $PropertiesPath
}
if ((Test-Path $resolvedKeystorePath) -and (Test-Path $resolvedPropertiesPath) -and -not $Force) {
Write-Output 'Keystore and properties already exist.'
Write-Output "Keystore: $resolvedKeystorePath"
Write-Output "Properties: $resolvedPropertiesPath"
exit 0
}
if (((Test-Path $resolvedKeystorePath) -xor (Test-Path $resolvedPropertiesPath)) -and -not $Force) {
throw 'Keystore and properties are in a partial state. Use -Force to recreate the pair.'
}
$keystoreDirectory = Split-Path -Parent $resolvedKeystorePath
if (-not (Test-Path $keystoreDirectory)) {
New-Item -ItemType Directory -Path $keystoreDirectory | Out-Null
}
$storePassword = New-Secret
$keyPassword = $storePassword
$javaHome = Resolve-JavaHome
$keytoolPath = Join-Path $javaHome 'bin\keytool.exe'
if (Test-Path $resolvedKeystorePath) {
Remove-Item $resolvedKeystorePath -Force
}
& $keytoolPath `
-genkeypair `
-keystore $resolvedKeystorePath `
-storetype PKCS12 `
-storepass $storePassword `
-keypass $keyPassword `
-alias $KeyAlias `
-keyalg RSA `
-keysize 4096 `
-validity 10000 `
-dname 'CN=QSfera Argus Upload, O=QSfera, C=RU' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "keytool failed with exit code $LASTEXITCODE."
}
$propertiesContent = @(
"QSFERA_RELEASE_KEYSTORE=$resolvedKeystorePath"
"QSFERA_RELEASE_KEYSTORE_PASSWORD=$storePassword"
"QSFERA_RELEASE_KEY_ALIAS=$KeyAlias"
"QSFERA_RELEASE_KEY_PASSWORD=$keyPassword"
) -join [Environment]::NewLine
[System.IO.File]::WriteAllText($resolvedPropertiesPath, $propertiesContent, [System.Text.Encoding]::ASCII)
Write-Output 'Created QSfera Argus upload keystore.'
Write-Output "Keystore: $resolvedKeystorePath"
Write-Output "Properties: $resolvedPropertiesPath"
@@ -0,0 +1,361 @@
param(
[string]$Slug = 'qsfera-mobile',
[string]$Name,
[string]$Summary = 'Android client for QSfera server.',
[string]$Description = 'Android client for QSfera server, prepared for Argus publication.',
[string]$Version,
[string]$Channel = 'stable',
[string]$Platform = 'android',
[string]$Notes,
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/QSfera.git',
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[string]$GitProvenanceRoot,
[switch]$Unlisted,
[switch]$SkipSigningInitialization,
[switch]$SkipGitProvenanceCheck
)
$ErrorActionPreference = 'Stop'
function Require-Tool([string]$Path, [string]$Label) {
if (-not (Test-Path $Path)) {
throw "$Label was not found: $Path"
}
}
function Get-ApkBadgingValue([string]$BadgingText, [string]$Pattern) {
$match = [regex]::Match($BadgingText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
if (-not $match.Success) {
return $null
}
return $match.Groups[1].Value
}
function Read-Properties([string]$Path) {
$result = @{}
foreach ($line in Get-Content $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) {
$java = Join-Path $candidate 'bin\java.exe'
if (Test-Path $java) {
return $candidate
}
}
throw 'java.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 (Join-Path $candidate 'build-tools')) {
return $candidate
}
}
throw 'Android SDK was not found. Set ANDROID_HOME to the SDK path.'
}
function Resolve-ArgusPlatform([string[]]$NativeCodes) {
$normalized = $NativeCodes | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() }
$allUniversalAbis = @('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64')
if (@($allUniversalAbis | Where-Object { $normalized -contains $_ }).Count -eq $allUniversalAbis.Count) {
return 'android-universal'
}
if ($normalized -contains 'arm64-v8a') {
return 'android-arm64'
}
if ($normalized -contains 'armeabi-v7a') {
return 'android-armv7'
}
if ($normalized -contains 'x86_64') {
return 'android-x86_64'
}
if ($normalized -contains 'x86') {
return 'android-x86'
}
return 'android'
}
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) {
throw "$ToolName was not found under Android build-tools."
}
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
$env:JAVA_HOME = $javaHome
$env:ANDROID_HOME = $androidHome
$gradlePath = Join-Path $repoRoot 'gradlew.bat'
$aaptPath = Find-FirstTool $androidHome 'aapt.exe'
$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat'
$signingPropertiesPath = Join-Path $repoRoot 'argus-signing.local.properties'
$signingInitScript = Join-Path $PSScriptRoot 'new-argus-upload-keystore.ps1'
Require-Tool $gradlePath 'Gradle wrapper'
Require-Tool $aaptPath 'aapt'
Require-Tool $apksignerPath 'apksigner'
Require-Tool $signingInitScript 'Argus signing init script'
if (-not (Test-Path $signingPropertiesPath)) {
if ($SkipSigningInitialization) {
throw "Signing properties were not found: $signingPropertiesPath"
}
& $signingInitScript
if ($LASTEXITCODE -ne 0) {
throw "Signing initialization failed with exit code $LASTEXITCODE."
}
}
$signingProperties = Read-Properties $signingPropertiesPath
foreach ($key in @(
'QSFERA_RELEASE_KEYSTORE',
'QSFERA_RELEASE_KEYSTORE_PASSWORD',
'QSFERA_RELEASE_KEY_ALIAS',
'QSFERA_RELEASE_KEY_PASSWORD'
)) {
if (-not $signingProperties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($signingProperties[$key])) {
throw "Signing property is missing: $key"
}
}
$env:QSFERA_RELEASE_KEYSTORE = $signingProperties['QSFERA_RELEASE_KEYSTORE']
$env:QSFERA_RELEASE_KEYSTORE_PASSWORD = $signingProperties['QSFERA_RELEASE_KEYSTORE_PASSWORD']
$env:QSFERA_RELEASE_KEY_ALIAS = $signingProperties['QSFERA_RELEASE_KEY_ALIAS']
$env:QSFERA_RELEASE_KEY_PASSWORD = $signingProperties['QSFERA_RELEASE_KEY_PASSWORD']
& $gradlePath :qsferaApp:assembleOriginalRelease --console=plain
if ($LASTEXITCODE -ne 0) {
throw "Gradle release build failed with exit code $LASTEXITCODE."
}
$outputMetadataPath = Join-Path $repoRoot 'qsferaApp\build\outputs\apk\original\release\output-metadata.json'
if (-not (Test-Path $outputMetadataPath)) {
throw "Release output metadata was not found: $outputMetadataPath"
}
$outputMetadata = Get-Content $outputMetadataPath -Raw | ConvertFrom-Json
$outputFile = $outputMetadata.elements[0].outputFile
$releaseApkPath = Join-Path $repoRoot ("qsferaApp\build\outputs\apk\original\release\{0}" -f $outputFile)
if (-not (Test-Path $releaseApkPath)) {
throw "Release APK was not found: $releaseApkPath"
}
if ($releaseApkPath -like '*-unsigned.apk') {
throw "Release APK is unsigned: $releaseApkPath"
}
$badging = & $aaptPath dump badging $releaseApkPath
if ($LASTEXITCODE -ne 0) {
throw "aapt badging failed with exit code $LASTEXITCODE."
}
$badgingText = ($badging | Out-String)
$packageName = Get-ApkBadgingValue $badgingText "package: name='([^']+)'"
$versionCode = Get-ApkBadgingValue $badgingText "versionCode='([^']+)'"
$versionName = Get-ApkBadgingValue $badgingText "versionName='([^']+)'"
$targetSdk = Get-ApkBadgingValue $badgingText "targetSdkVersion:'([^']+)'"
$applicationLabel = Get-ApkBadgingValue $badgingText "application-label:'([^']+)'"
$nativeCodeLine = ($badging | Where-Object { $_ -like 'native-code:*' } | Select-Object -First 1)
$nativeCodes = if ($nativeCodeLine) {
[regex]::Matches($nativeCodeLine, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value }
} else {
@()
}
$detectedPlatform = Resolve-ArgusPlatform $nativeCodes
$resolvedPlatform = if ([string]::IsNullOrWhiteSpace($Platform)) {
$detectedPlatform
} else {
$Platform.Trim()
}
if (-not $packageName) {
throw 'Package name could not be extracted from APK.'
}
if ($packageName -ne 'eu.qsfera.android') {
throw "Unexpected package name: $packageName"
}
if (-not $versionCode) {
throw 'Version code could not be extracted from APK.'
}
if (-not $versionName) {
throw 'Version name could not be extracted from APK.'
}
if (-not $targetSdk) {
throw 'Target SDK could not be extracted from APK.'
}
$certOutput = & $apksignerPath verify --print-certs $releaseApkPath
if ($LASTEXITCODE -ne 0) {
throw "apksigner verification failed with exit code $LASTEXITCODE."
}
$certText = ($certOutput | Out-String)
$certificateSha256 = Get-ApkBadgingValue $certText "SHA-256 digest: ([0-9a-fA-F]+)"
if ($certText -match 'CN=Android Debug' -or $certificateSha256 -eq 'b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941') {
throw 'Release APK is signed with the Android debug certificate.'
}
$resolvedName = if ($Name) { $Name } elseif ($applicationLabel) { $applicationLabel } else { 'QSfera' }
$releaseVersion = if ($Version) { $Version } else { $versionName }
$stageDirectory = Join-Path $repoRoot ("build\argus-publication\v{0}" -f $versionCode)
if (-not (Test-Path $stageDirectory)) {
New-Item -ItemType Directory -Path $stageDirectory | Out-Null
}
$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
slug = $Slug
name = $resolvedName
summary = $Summary
description = $Description
version = $releaseVersion
channel = $Channel
platform = $resolvedPlatform
packageKind = 'apk'
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
isListed = (-not $Unlisted)
verifiedFromApk = [ordered]@{
outputFile = $releaseApkPath
packageName = $packageName
versionCode = [int]$versionCode
versionName = $versionName
targetSdkVersion = [int]$targetSdk
nativeCodes = $nativeCodes
detectedPlatform = $detectedPlatform
applicationLabel = if ($applicationLabel) { $applicationLabel } else { $null }
certificateSha256 = $certificateSha256
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
}
}
$metadataPath = Join-Path $stageDirectory 'argus-publication.json'
$metadata | ConvertTo-Json -Depth 6 | Set-Content -Path $metadataPath -Encoding UTF8
Write-Output 'Prepared QSfera Argus publication files.'
Write-Output "APK: $stagedApkPath"
Write-Output "Metadata: $metadataPath"
+372
View File
@@ -0,0 +1,372 @@
param(
[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'
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Get-LatestMetadataPath {
$repoRoot = Split-Path -Parent $PSScriptRoot
$publicationRoot = Join-Path $repoRoot 'build\argus-publication'
if (-not (Test-Path -LiteralPath $publicationRoot)) {
Fail "Argus publication directory was not found: $publicationRoot"
}
$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"
}
return $metadata.FullName
}
function Get-RequiredString($Object, [string]$Name) {
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
Fail "Publication metadata is missing '$Name'."
}
return [string]$property.Value
}
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 {
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 {
Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue
}
$manifestUrl = "$($PublicBaseUrl.TrimEnd('/'))/api/apps/$slug/manifest?platform=$platform&channel=$channel"
$manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 30
$release = $manifest.release
if ($null -eq $release) {
Fail "Published manifest did not return a release: $manifestUrl"
}
Assert-ManifestValue $manifest 'slug' $manifest.slug $slug
Assert-ManifestValue $release 'version' $release.version $version
Assert-ManifestValue $release 'channel' $release.channel $channel
Assert-ManifestValue $release 'platform' $release.platform $platform
Assert-ManifestValue $release 'packageKind' $release.packageKind $packageKind
Assert-ManifestValue $release 'originalFileName' $release.originalFileName $packageFileName
Assert-ManifestValue $release 'packageSizeBytes' $release.packageSizeBytes $localSize
Assert-ManifestValue $release 'sha256' $release.sha256 $localSha256
Write-Output 'QSfera Android Argus SSH publication completed.'
Write-Output "Manifest: $manifestUrl"
Write-Output "Size: $localSize"
Write-Output "SHA-256: $localSha256"
@@ -0,0 +1,183 @@
param(
[string]$PropertiesPath = 'argus-signing.local.properties',
[string]$ExpectedCertificateSha256 = 'ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877',
[string]$PublishedApkUrl = 'https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable',
[switch]$VerifyPublishedApk
)
$ErrorActionPreference = 'Stop'
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Read-Properties([string]$Path) {
$result = @{}
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
continue
}
$separator = $trimmed.IndexOf('=')
if ($separator -le 0) {
continue
}
$key = $trimmed.Substring(0, $separator).Trim()
$value = $trimmed.Substring($separator + 1).Trim()
$result[$key] = $value
}
return $result
}
function Resolve-JavaHome {
$candidates = @(
$env:JAVA_HOME,
'C:\Program Files\Android\Android Studio\jbr',
'C:\Program Files\Android\openjdk\jdk-21.0.8'
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
$keytool = Join-Path $candidate 'bin\keytool.exe'
if (Test-Path -LiteralPath $keytool) {
return $candidate
}
}
Fail 'keytool.exe was not found. Set JAVA_HOME to a JDK path.'
}
function Resolve-AndroidHome {
$candidates = @(
$env:ANDROID_HOME,
"$env:LOCALAPPDATA\Android\Sdk"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath (Join-Path $candidate 'build-tools')) {
return $candidate
}
}
Fail 'Android SDK was not found. Set ANDROID_HOME to the SDK path.'
}
function Find-FirstTool([string]$AndroidHome, [string]$ToolName) {
$tool = Get-ChildItem -Path (Join-Path $AndroidHome 'build-tools') -Recurse -File -Filter $ToolName |
Sort-Object FullName -Descending |
Select-Object -First 1
if (-not $tool) {
Fail "$ToolName was not found under Android build-tools."
}
return $tool.FullName
}
function Normalize-Sha256([string]$Value) {
return ($Value -replace '[^0-9a-fA-F]', '').ToLowerInvariant()
}
function Get-KeytoolCertificateSha256([string]$KeytoolPath, [string]$KeystorePath, [string]$Password, [string]$Alias) {
$env:QSFERA_VERIFY_KEYSTORE_PASSWORD = $Password
try {
$output = & $KeytoolPath -list -v -keystore $KeystorePath -storepass:env QSFERA_VERIFY_KEYSTORE_PASSWORD -alias $Alias 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "keytool could not read the release keystore alias '$Alias'. Output: $($output | Out-String)"
}
} finally {
Remove-Item Env:\QSFERA_VERIFY_KEYSTORE_PASSWORD -ErrorAction SilentlyContinue
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA256:\s*([0-9A-Fa-f:]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA256 certificate fingerprint from keytool output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
function Get-ApkCertificateSha256([string]$ApksignerPath, [string]$ApkPath) {
$output = & $ApksignerPath verify --print-certs $ApkPath 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "apksigner could not verify APK certificate. Output: $($output | Out-String)"
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA-256 digest:\s*([0-9A-Fa-f]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA-256 certificate fingerprint from apksigner output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) {
$PropertiesPath
} else {
Join-Path $repoRoot $PropertiesPath
}
if (-not (Test-Path -LiteralPath $resolvedPropertiesPath)) {
Fail "Signing properties were not found: $resolvedPropertiesPath"
}
$properties = Read-Properties $resolvedPropertiesPath
foreach ($key in @(
'QSFERA_RELEASE_KEYSTORE',
'QSFERA_RELEASE_KEYSTORE_PASSWORD',
'QSFERA_RELEASE_KEY_ALIAS',
'QSFERA_RELEASE_KEY_PASSWORD'
)) {
if (-not $properties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($properties[$key])) {
Fail "Signing property is missing: $key"
}
}
$keystorePath = $properties['QSFERA_RELEASE_KEYSTORE']
if (-not [System.IO.Path]::IsPathRooted($keystorePath)) {
$keystorePath = Join-Path $repoRoot $keystorePath
}
if (-not (Test-Path -LiteralPath $keystorePath)) {
Fail "Release keystore was not found: $keystorePath"
}
if ((Get-Item -LiteralPath $keystorePath).Length -le 0) {
Fail "Release keystore is empty: $keystorePath"
}
$javaHome = Resolve-JavaHome
$env:JAVA_HOME = $javaHome
$keytoolPath = Join-Path $javaHome 'bin\keytool.exe'
$expectedSha256 = Normalize-Sha256 $ExpectedCertificateSha256
$alias = $properties['QSFERA_RELEASE_KEY_ALIAS']
$keytoolSha256 = Get-KeytoolCertificateSha256 `
-KeytoolPath $keytoolPath `
-KeystorePath $keystorePath `
-Password $properties['QSFERA_RELEASE_KEYSTORE_PASSWORD'] `
-Alias $alias
if ($keytoolSha256 -ne $expectedSha256) {
Fail "Release keystore certificate SHA-256 expected $expectedSha256 but got $keytoolSha256."
}
Write-Output "OK: release signing properties are complete: $resolvedPropertiesPath"
Write-Output "OK: release keystore exists and is readable: $keystorePath"
Write-Output "OK: release key alias '$alias' certificate SHA-256 = $keytoolSha256"
if ($VerifyPublishedApk) {
$androidHome = Resolve-AndroidHome
$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat'
$tempApk = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), '.apk')
try {
Invoke-WebRequest -Uri $PublishedApkUrl -UseBasicParsing -TimeoutSec 120 -OutFile $tempApk | Out-Null
$publishedSha256 = Get-ApkCertificateSha256 -ApksignerPath $apksignerPath -ApkPath $tempApk
if ($publishedSha256 -ne $expectedSha256) {
Fail "Published APK certificate SHA-256 expected $expectedSha256 but got $publishedSha256."
}
Write-Output "OK: published Argus APK certificate SHA-256 = $publishedSha256"
} finally {
Remove-Item -LiteralPath $tempApk -Force -ErrorAction SilentlyContinue
}
}
+16
View File
@@ -57,6 +57,8 @@ jobs:
CRAFT_TARGET: ${{ matrix.target }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CRAFT_PACKAGE_SYMBOLS: ${{ github.event_name != 'pull_request' }}
SIGN_PACKAGE: ${{ secrets.QSFERA_DESKTOP_SIGN_PACKAGE }}
CRAFT_CODESIGN_CERTIFICATE: ${{ secrets.QSFERA_DESKTOP_CODESIGN_CERTIFICATE }}
container: ${{ matrix.container }}
@@ -188,11 +190,25 @@ jobs:
- name: Package
if: matrix.target != 'macos-clang-arm64'
run: |
$requireSigning = "${{ matrix.os }}" -eq "windows-latest" -and "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta"
if ("${{ matrix.os }}" -eq "windows-latest") {
$validationArgs = @()
if ($requireSigning) {
$validationArgs += "-RequireSigning"
}
& "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs
}
& "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package opencloud/opencloud-desktop
- name: Package Appx
if: ${{ matrix.os == 'windows-latest' && github.event_name != 'pull_request' }}
run: |
$requireSigning = "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta"
$validationArgs = @()
if ($requireSigning) {
$validationArgs += "-RequireSigning"
}
& "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs
& "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package --options "[Packager]PackageType=AppxPackager" --options "[Packager]Destination=${{ github.workspace }}/appx/" opencloud/opencloud-desktop
- name: Prepare artifacts
+1
View File
@@ -5,6 +5,7 @@ set( APPLICATION_VENDOR "QSfera" )
set( APPLICATION_ICON_NAME "qsfera" )
set( APPLICATION_REV_DOMAIN "eu.qsfera.desktop" )
set( APPLICATION_DEFAULT_SERVER_URL "https://qsfera.kusoft.xyz" )
set( APPLICATION_UPDATE_URL "https://qsfera.kusoft.xyz/desktop/updates/stable.xml" )
if(BETA_CHANNEL_BUILD)
set( APPLICATION_NAME "${APPLICATION_NAME} Beta")
+65
View File
@@ -0,0 +1,65 @@
# Windows Update Feed
QSfera Desktop uses the built-in Windows updater from `src/gui/updater`.
The updater reads `APPLICATION_UPDATE_URL`, appends query parameters such as
`version`, `platform=win32`, and `msi=true`, and expects an XML document with
root element `QSfera`.
Production feed URL:
```text
https://qsfera.kusoft.xyz/desktop/updates/stable.xml
```
The no-update feed is valid when no signed Windows MSI is available yet:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<QSfera>
<version></version>
<versionstring></versionstring>
<web>https://qsfera.kusoft.xyz/</web>
<downloadurl></downloadurl>
</QSfera>
```
To advertise a signed MSI update, publish the MSI over HTTPS and replace the
feed with a concrete version:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<QSfera>
<version>1.3.7</version>
<versionstring>1.3.7</versionstring>
<web>https://qsfera.kusoft.xyz/</web>
<downloadurl>https://qsfera.kusoft.xyz/desktop/downloads/QSfera_Desktop-1.3.7-x64.msi</downloadurl>
</QSfera>
```
From the repository root, prepare the feed XML from a signed MSI and optionally
upload the MSI/feed to the web host:
```powershell
.\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`
also verifies this live feed.
@@ -0,0 +1,104 @@
[CmdletBinding(DefaultParameterSetName = "Url")]
param(
[Parameter(Mandatory = $true, ParameterSetName = "Url")]
[string]$Url,
[Parameter(Mandatory = $true, ParameterSetName = "Path")]
[string]$Path
)
$ErrorActionPreference = "Stop"
function Fail($Message) {
Write-Error $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"
}
if ($parsedUrl.Scheme -ne "https") {
Fail "Desktop update feed URL must use HTTPS: $Url"
}
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "Desktop update feed request failed for ${Url}: $($_.Exception.Message)"
}
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 = $feedContent
} catch {
Fail "Desktop update feed is not valid XML: $($_.Exception.Message)"
}
if ($xml.DocumentElement.Name -ne "QSfera") {
Fail "Desktop update feed root element must be <QSfera>."
}
$requiredElements = @("version", "versionstring", "web", "downloadurl")
foreach ($name in $requiredElements) {
if ($null -eq $xml.QSfera.$name) {
Fail "Desktop update feed is missing <$name>."
}
}
$version = [string]$xml.QSfera.version
$versionString = [string]$xml.QSfera.versionstring
$web = [string]$xml.QSfera.web
$downloadUrl = [string]$xml.QSfera.downloadurl
if (-not [string]::IsNullOrWhiteSpace($web)) {
$webUri = $null
if (-not [System.Uri]::TryCreate($web, [System.UriKind]::Absolute, [ref]$webUri) -or $webUri.Scheme -ne "https") {
Fail "Desktop update feed <web> must be an absolute HTTPS URL when present."
}
}
if ([string]::IsNullOrWhiteSpace($version)) {
if (-not [string]::IsNullOrWhiteSpace($downloadUrl)) {
Fail "Desktop no-update feed must leave <downloadurl> empty when <version> is empty."
}
} else {
$parsedVersion = $null
if (-not [System.Version]::TryParse($version, [ref]$parsedVersion)) {
Fail "Desktop update feed <version> must be parseable as a version number."
}
if ([string]::IsNullOrWhiteSpace($versionString)) {
Fail "Desktop update feed must set <versionstring> when <version> is present."
}
$downloadUri = $null
if (-not [System.Uri]::TryCreate($downloadUrl, [System.UriKind]::Absolute, [ref]$downloadUri) -or $downloadUri.Scheme -ne "https") {
Fail "Desktop update feed <downloadurl> must be an absolute HTTPS URL when an update is advertised."
}
if (-not $downloadUri.AbsolutePath.EndsWith(".msi", [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "Desktop Windows updater expects an MSI download URL."
}
}
Write-Host "QSfera Desktop update feed validation passed: $feedSource"
@@ -0,0 +1,76 @@
param(
[switch]$RequireSigning,
[switch]$VerifyUpdateFeed,
[string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
)
$ErrorActionPreference = "Stop"
function Fail($Message) {
Write-Error $Message
exit 1
}
function Read-Text($RelativePath) {
Get-Content -LiteralPath (Join-Path $Root $RelativePath) -Raw -Encoding UTF8
}
$branding = Read-Text "QSFERA.cmake"
$cmake = Read-Text "CMakeLists.txt"
$craft = Read-Text ".craft.ini"
$expectedAppName = -join ([char[]](0x041a, 0x0443, 0x0421, 0x0444, 0x0435, 0x0440, 0x0430))
if (-not $branding.Contains("APPLICATION_NAME") -or -not $branding.Contains($expectedAppName)) {
Fail "Desktop branding must use the production Cyrillic application name."
}
if ($branding -notmatch 'APPLICATION_EXECUTABLE\s+"qsfera"') {
Fail "Desktop executable must remain `"qsfera`" for the production profile."
}
if ($branding -notmatch 'APPLICATION_REV_DOMAIN\s+"eu\.qsfera\.desktop"') {
Fail "Desktop reverse-domain id must remain eu.qsfera.desktop for the production profile."
}
if ($branding -notmatch 'APPLICATION_DEFAULT_SERVER_URL\s+"https://qsfera\.kusoft\.xyz"') {
Fail "Desktop default server URL must be https://qsfera.kusoft.xyz."
}
if ($branding -notmatch 'APPLICATION_UPDATE_URL\s+"https://qsfera\.kusoft\.xyz/desktop/updates/stable\.xml"') {
Fail "Desktop update feed URL must be https://qsfera.kusoft.xyz/desktop/updates/stable.xml."
}
if ($cmake -notmatch 'option\(WITH_UPDATE_NOTIFICATION\s+"Whether to check for new releases"\s+ON\)') {
Fail "Desktop release builds must keep update notifications enabled by default."
}
if ($cmake -notmatch 'VIRTUAL_FILE_SYSTEM_PLUGINS\s+off\s+cfapi\s+openvfs') {
Fail "Desktop release builds must include the Windows CfAPI VFS plugin."
}
if ($craft -notmatch 'CodeSigning/Enabled\s*=\s*\$\{Env:SIGN_PACKAGE\}') {
Fail "Craft code-signing must be controlled by SIGN_PACKAGE."
}
if ($RequireSigning) {
if ($env:SIGN_PACKAGE -notin @("True", "true", "1", "yes", "YES")) {
Fail "Production Windows tag releases require SIGN_PACKAGE=True."
}
if ([string]::IsNullOrWhiteSpace($env:CRAFT_CODESIGN_CERTIFICATE)) {
Fail "Production Windows tag releases require CRAFT_CODESIGN_CERTIFICATE."
}
$VerifyUpdateFeed = $true
}
if ($VerifyUpdateFeed) {
$feedValidator = Join-Path $PSScriptRoot "validate-desktop-update-feed.ps1"
if (-not (Test-Path -LiteralPath $feedValidator)) {
Fail "Desktop update feed validator was not found: $feedValidator"
}
& $feedValidator -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
}
Write-Host "QSfera Desktop production release validation passed."
+4 -4
View File
@@ -194,13 +194,13 @@ void AccountSettings::slotRemoveCurrentFolder(Folder *folder)
{
// TODO: move to qml
qCInfo(lcAccountSettings) << u"Remove Folder " << folder->path();
auto messageBox = new FontIconMessageBox({Resources::FontIcon::DefaultGlyphes::Question}, tr("Confirm removal of Space"),
tr("<p>Do you really want to stop syncing the Space »%1«?</p>"
"<p><b>Note:</b> This will <b>not</b> delete any files.</p>")
auto messageBox = new FontIconMessageBox({Resources::FontIcon::DefaultGlyphes::Question}, tr("Confirm removal of folder sync"),
tr("<p>Stop syncing <b>%1</b> on this computer?</p>"
"<p><b>Note:</b> This will not delete files in QSfera or on this device.</p>")
.arg(folder->displayName()),
QMessageBox::NoButton, ocApp()->settingsDialog());
messageBox->setAttribute(Qt::WA_DeleteOnClose);
QPushButton *yesButton = messageBox->addButton(tr("Remove Space"), QMessageBox::YesRole);
QPushButton *yesButton = messageBox->addButton(tr("Remove folder sync"), QMessageBox::YesRole);
messageBox->addButton(tr("Cancel"), QMessageBox::NoRole);
connect(messageBox, &QMessageBox::finished, this, [messageBox, yesButton, folder, this] {
if (messageBox->clickedButton() == yesButton) {
+2 -2
View File
@@ -55,7 +55,7 @@
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Preparing the account</string>
<string>Preparing account</string>
</property>
</widget>
</item>
@@ -83,7 +83,7 @@
</widget>
<widget class="OCC::QmlUtils::OCQuickWidget" name="quickWidget">
<property name="accessibleName">
<string>Sync connections</string>
<string>Synchronized folders</string>
</property>
</widget>
</widget>
+56 -11
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)) {
const auto hwnd = reinterpret_cast<HWND>(window->winId());
SetForegroundWindow(hwnd);
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
AttachThreadInput(threadId, activeProcessId, false);
}
// 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_SHOWWINDOW);
if (attachedToForegroundThread) {
AttachThreadInput(currentThreadId, foregroundThreadId, false);
}
#endif
}
+6 -6
View File
@@ -77,7 +77,7 @@
<item row="0" column="0">
<widget class="QCheckBox" name="autostartCheckBox">
<property name="text">
<string>Start on Login</string>
<string>Start QSfera when I sign in</string>
</property>
</widget>
</item>
@@ -95,7 +95,7 @@
<item>
<widget class="QCheckBox" name="syncHiddenFilesCheckBox">
<property name="text">
<string>Sync hidden files</string>
<string>Sync hidden files and folders</string>
</property>
</widget>
</item>
@@ -108,14 +108,14 @@
</sizepolicy>
</property>
<property name="text">
<string>Show crash reporter</string>
<string>Show crash reporter after a crash</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="moveToTrashCheckBox">
<property name="text">
<string>Move remotely deleted files to the local trash bin instead of deleting them</string>
<string>Move server-deleted files to the local trash instead of deleting them</string>
</property>
</widget>
</item>
@@ -124,14 +124,14 @@
<item>
<widget class="QPushButton" name="ignoredFilesButton">
<property name="text">
<string>Edit Ignored Files</string>
<string>Ignored files</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="logSettingsButton">
<property name="text">
<string>Log Settings</string>
<string>Logs</string>
</property>
</widget>
</item>
+8 -8
View File
@@ -22,14 +22,14 @@ import eu.QSfera.resources 1.0
Pane {
id: folderSyncPanel
// TODO: not cool
readonly property real normalSize: 170
readonly property real normalSize: 140
readonly property AccountSettings accountSettings: ocContext
readonly property OCQuickWidget widget: ocQuickWidget
spacing: 10
Accessible.role: Accessible.List
Accessible.name: qsTr("Folder Sync")
Accessible.name: qsTr("Synchronized folders")
Connections {
target: widget
@@ -69,7 +69,7 @@ Pane {
Button {
id: manageAccountButton
text: qsTr("Manage Account")
text: qsTr("Account")
Menu {
id: accountMenu
@@ -89,14 +89,14 @@ Pane {
}
MenuItem {
text: qsTr("Remove")
text: qsTr("Remove account")
onTriggered: accountSettings.slotDeleteAccount()
}
}
onClicked: {
accountMenu.open();
Accessible.announce(qsTr("Account options Menu"));
Accessible.announce(qsTr("Account options menu"));
}
Keys.onBacktabPressed: {
@@ -352,7 +352,7 @@ Pane {
}
MenuItem {
text: qsTr("Remove Space")
text: qsTr("Remove folder sync")
onTriggered: accountSettings.slotRemoveCurrentFolder(folderDelegate.folder)
visible: !folderDelegate.isDeployed
height: visible ? implicitHeight : 0
@@ -371,7 +371,7 @@ Pane {
Button {
id: addSyncButton
text: qsTr("Add Space")
text: qsTr("Add folder sync")
onClicked: {
accountSettings.slotAddFolder();
@@ -391,7 +391,7 @@ Pane {
Layout.fillWidth: true
}
Label {
text: qsTr("You are synchronizing %1 out of %2 Spaces").arg(accountSettings.syncedSpaces).arg(accountSettings.syncedSpaces + accountSettings.unsyncedSpaces)
text: qsTr("Synchronized folders: %1 of %2").arg(accountSettings.syncedSpaces).arg(accountSettings.syncedSpaces + accountSettings.unsyncedSpaces)
visible: accountSettings.accountState.state === AccountState.Connected
}
}
+9 -3
View File
@@ -24,6 +24,7 @@ Item {
property alias imageSource: image.source
property alias statusSource: statusIcon.source
property real imageSize: 96
property real spacing: 10
default property alias content: colLayout.data
@@ -39,15 +40,20 @@ Item {
Rectangle {
Accessible.ignored: true
Layout.preferredHeight: normalSize - 20
Layout.preferredWidth: normalSize - 20
Layout.preferredHeight: imageSize
Layout.preferredWidth: imageSize
Layout.alignment: Qt.AlignTop
color: spaceDelegate.palette.alternateBase
radius: 6
border.color: spaceDelegate.palette.mid
border.width: 1
clip: true
Image {
id: image
anchors.fill: parent
anchors.margins: 8
fillMode: Image.PreserveAspectFit
sourceSize.width: width
sourceSize.height: height
@@ -74,7 +80,7 @@ Item {
Accessible.ignored: true
Layout.fillWidth: true
font.bold: true
font.pointSize: 15
font.pointSize: 13
elide: Text.ElideRight
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ import eu.QSfera.libsync 1.0
Pane {
id: spacesView
// TODO: not cool
readonly property real normalSize: 170
readonly property real normalSize: 140
readonly property SpacesBrowser spacesBrowser: ocContext
readonly property OCQuickWidget widget: ocQuickWidget
+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"));
+1 -1
View File
@@ -331,7 +331,7 @@ void ConfigFile::setSkipUpdateCheck(bool skip)
QString ConfigFile::updateChannel() const
{
auto settings = makeQSettings();
return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"stable"_s : u"beta"_s).toString();
return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"beta"_s : u"stable"_s).toString();
}
void ConfigFile::setUpdateChannel(const QString &channel)
+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 -2
View File
@@ -33,8 +33,10 @@ Resources::Icon Resources::parseIcon(const QString &id)
QPixmap Resources::pixmap(const QSize &requestedSize, const QIcon &icon, QIcon::Mode mode, QSize *outSize)
{
Q_ASSERT(requestedSize.isValid());
QSize actualSize = requestedSize.isValid() ? requestedSize : icon.availableSizes().constFirst();
QSize actualSize = requestedSize.isValid() && !requestedSize.isEmpty() ? requestedSize : icon.actualSize(minIconSize);
if (!actualSize.isValid() || actualSize.isEmpty()) {
actualSize = minIconSize;
}
if (outSize) {
*outSize = actualSize;
}
+1
View File
@@ -13,6 +13,7 @@ qsfera_add_test(ConcatUrl)
qsfera_add_test(XmlParse)
qsfera_add_test(ChecksumValidator)
qsfera_add_test(ConnectionValidator)
qsfera_add_test(ConfigFile)
# TODO: we need keychain access for this test
@@ -23,6 +23,6 @@ Feature: remove account connection
And user "Alice" has set up a client with default settings
When the user removes the connection for user "Alice"
Then the settings tab should have the following options in the general section:
| Start on Login |
| Start QSfera when I sign in |
And the folder "large-folder" should exist on the file system
And the file "testFile.txt" should exist on the file system
@@ -19,11 +19,11 @@ Feature: Visually check all tabs
And user "Alice" has set up a client with default settings
When the user opens the settings tab
Then the settings tab should have the following options in the general section:
| Start on Login |
| Start QSfera when I sign in |
And the settings tab should have the following options in the advanced section:
| Sync hidden files |
| Edit ignored files |
| Log settings |
| Sync hidden files and folders |
| Ignored files |
| Logs |
And the settings tab should have the following options in the network section:
| Download Bandwidth |
| Upload Bandwidth |
@@ -11,16 +11,16 @@ from helpers.SyncHelper import wait_for
class AccountSetting:
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
by=By.NAME, selector="Sync connections"
by=By.NAME, selector="Synchronized folders"
)
MANAGE_ACCOUNT_BUTTON = SimpleNamespace(by=By.NAME, selector="Manage Account")
MANAGE_ACCOUNT_BUTTON = SimpleNamespace(by=By.NAME, selector="Account")
ACCOUNT_MENU = SimpleNamespace(by=By.NAME, selector="{menu_item}")
CONFIRM_REMOVE_CONNECTION_BUTTON = SimpleNamespace(
by=By.NAME, selector="Remove connection"
)
ACCOUNT_CONNECTION_LABEL = SimpleNamespace(
by=By.XPATH,
selector="//list[@name='Folder Sync']//label",
selector="//list[@name='Synchronized folders']//label",
)
LOG_BROWSER_WINDOW = SimpleNamespace(by=None, selector=None)
ACCOUNT_LOADING = SimpleNamespace(by=None, selector=None)
@@ -50,7 +50,7 @@ class AccountSetting:
@staticmethod
def remove_account_connection():
AccountSetting.account_action("Remove")
AccountSetting.account_action("Remove account")
app().find_element(
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.by,
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.selector,
+8 -8
View File
@@ -11,19 +11,19 @@ class Settings:
ABOUT_DIALOG = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | About]")
ABOUT_DIALOG_OK_BUTTON = SimpleNamespace(by=By.NAME, selector="OK")
GENERAL_SETTING_START_ON_LOGIN = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Start on Login']"
by=By.XPATH, selector="//panel/*[@name='Start QSfera when I sign in']"
)
GENERAL_SETTING_LANGUAGE = SimpleNamespace(
by=By.XPATH, selector="//panel/label[@name='Language']"
)
ADVANCED_SETTING_SYNC_HIDDEN_FILES = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Sync hidden files']"
by=By.XPATH, selector="//panel/*[@name='Sync hidden files and folders']"
)
ADVANCED_SETTING_EDIT_IGNORED_FILES = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Edit Ignored Files']"
by=By.XPATH, selector="//panel/*[@name='Ignored files']"
)
ADVANCED_SETTING_LOG_SETTINGS = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Log Settings']"
by=By.XPATH, selector="//panel/*[@name='Logs']"
)
NETWORK_SETTING_DOWNLOAD_BANDWIDTH = SimpleNamespace(
by=By.XPATH, selector="//panel[@name='Download Bandwidth']"
@@ -50,7 +50,7 @@ class Settings:
@staticmethod
def has_general_setting(setting):
if setting.lower() == "start on login":
if setting.lower() == "start qsfera when i sign in":
locator = Settings.GENERAL_SETTING_START_ON_LOGIN
elif setting.lower() == "language":
locator = Settings.GENERAL_SETTING_LANGUAGE
@@ -60,11 +60,11 @@ class Settings:
@staticmethod
def has_advanced_setting(setting):
if setting.lower() == "sync hidden files":
if setting.lower() == "sync hidden files and folders":
locator = Settings.ADVANCED_SETTING_SYNC_HIDDEN_FILES
elif setting.lower() == "edit ignored files":
elif setting.lower() == "ignored files":
locator = Settings.ADVANCED_SETTING_EDIT_IGNORED_FILES
elif setting.lower() == "log settings":
elif setting.lower() == "logs":
locator = Settings.ADVANCED_SETTING_LOG_SETTINGS
else:
raise ValueError(f"Unknown advanced setting: {setting}")
@@ -8,7 +8,7 @@ from helpers.AppHelper import app
class SyncConnection:
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
by=By.NAME, selector="Sync connections"
by=By.NAME, selector="Synchronized folders"
)
FOLDER_SYNC_CONNECTION_MENU_BUTTON = SimpleNamespace(
by=By.NAME,
@@ -18,7 +18,7 @@ class SyncConnection:
SELECTIVE_SYNC_APPLY_BUTTON = SimpleNamespace(by=None, selector=None)
CANCEL_FOLDER_SYNC_CONNECTION_DIALOG = SimpleNamespace(by=None, selector=None)
CONFIRM_FOLDER_SYNC_CONNECTION_REMOVE = SimpleNamespace(
by=By.NAME, selector="Remove Space"
by=By.NAME, selector="Remove folder sync"
)
PERMISSION_ERROR_LABEL = SimpleNamespace(by=None, selector=None)
@@ -98,7 +98,7 @@ class SyncConnection:
@staticmethod
def remove_folder_sync_connection():
SyncConnection.perform_action("Remove Space")
SyncConnection.perform_action("Remove folder sync")
@staticmethod
def cancel_folder_sync_connection_removal():
@@ -35,7 +35,7 @@
```
- If not stated otherwise each test should be run on every platform win/linux/mac. Record the result as per platform.
- "Enable logging to temporary folder" and "Log Http traffic" (tab 'Settings', button 'Log Settings') to have log-files available if needed to report an issue.
- "Enable logging to temporary folder" and "Log Http traffic" (tab 'Settings', button 'Logs') to have log-files available if needed to report an issue.
- Make sure all automated UI tests run successfully in CI and run them locally on Windows.
- all tests that are automated are marked with a :robot: in the 'Result' column (for the corresponding platform) and the name of the test in the 'Related Comment' column
- If multiple people are working on the tests at the same time, mark the tests everyone is working on with the GitHub name of the tester.
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (C) QSfera
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
#include "common/version.h"
#include "configfile.h"
#include <QFile>
#include <QTemporaryDir>
#include <QtTest>
using namespace OCC;
using namespace Qt::Literals::StringLiterals;
class TestConfigFile : public QObject
{
Q_OBJECT
private Q_SLOTS:
void init()
{
QVERIFY(_configDir.isValid());
QVERIFY(ConfigFile::setConfDir(_configDir.path()));
QFile::remove(ConfigFile::configFile());
}
void testDefaultUpdateChannelMatchesBuildChannel()
{
const auto expectedChannel = Version::isBeta() ? u"beta"_s : u"stable"_s;
QCOMPARE(ConfigFile().updateChannel(), expectedChannel);
}
void testStoredUpdateChannelOverridesBuildDefault()
{
ConfigFile().setUpdateChannel(u"beta"_s);
QCOMPARE(ConfigFile().updateChannel(), u"beta"_s);
ConfigFile().setUpdateChannel(u"stable"_s);
QCOMPARE(ConfigFile().updateChannel(), u"stable"_s);
}
private:
QTemporaryDir _configDir;
};
QTEST_MAIN(TestConfigFile)
#include "testconfigfile.moc"
+18 -18
View File
@@ -230,12 +230,12 @@ File synchronization desktop utility.</source>
<name>FolderDelegate</name>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
<source>Folder Sync</source>
<source>Synchronized folders</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
<source>Manage Account</source>
<source>Account</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -255,12 +255,12 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
<source>Remove</source>
<source>Remove account</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
<source>Account options Menu</source>
<source>Account options menu</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -290,7 +290,7 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
<source>Remove Space</source>
<source>Remove folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -300,12 +300,12 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
<source>Add Space</source>
<source>Add folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="394"/>
<source>You are synchronizing %1 out of %2 Spaces</source>
<source>Synchronized folders: %1 of %2</source>
<translation type="unfinished"></translation>
</message>
</context>
@@ -601,12 +601,12 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="58"/>
<source>Preparing the account</source>
<source>Preparing account</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="86"/>
<source>Sync connections</source>
<source>Synchronized folders</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -622,17 +622,17 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="197"/>
<source>Confirm removal of Space</source>
<source>Confirm removal of folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="198"/>
<source>&lt;p&gt;Do you really want to stop syncing the Space »%1«?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will &lt;b&gt;not&lt;/b&gt; delete any files.&lt;/p&gt;</source>
<source>&lt;p&gt;Stop syncing &lt;b&gt;%1&lt;/b&gt; on this computer?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will not delete files in QSfera or on this device.&lt;/p&gt;</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="203"/>
<source>Remove Space</source>
<source>Remove folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -1269,7 +1269,7 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="80"/>
<source>Start on Login</source>
<source>Start QSfera when I sign in</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -1284,27 +1284,27 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="98"/>
<source>Sync hidden files</source>
<source>Sync hidden files and folders</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="111"/>
<source>Show crash reporter</source>
<source>Show crash reporter after a crash</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="118"/>
<source>Move remotely deleted files to the local trash bin instead of deleting them</source>
<source>Move server-deleted files to the local trash instead of deleting them</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="127"/>
<source>Edit Ignored Files</source>
<source>Ignored files</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="134"/>
<source>Log Settings</source>
<source>Logs</source>
<translation type="unfinished"></translation>
</message>
<message>
+37 -37
View File
@@ -231,13 +231,13 @@ File synchronization desktop utility.</source>
<name>FolderDelegate</name>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
<source>Folder Sync</source>
<translation>Синхронизация папки</translation>
<source>Synchronized folders</source>
<translation>Синхронизированные папки</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
<source>Manage Account</source>
<translation>Управление аккаунтом</translation>
<source>Account</source>
<translation>Аккаунт</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="78"/>
@@ -256,13 +256,13 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
<source>Remove</source>
<translation>Удалить</translation>
<source>Remove account</source>
<translation>Удалить аккаунт</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
<source>Account options Menu</source>
<translation>Меню настроек аккаунта</translation>
<source>Account options menu</source>
<translation>Меню аккаунта</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="332"/>
@@ -291,8 +291,8 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
<source>Remove Space</source>
<translation>Удалить Пространство</translation>
<source>Remove folder sync</source>
<translation>Удалить синхронизацию папки</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="362"/>
@@ -301,13 +301,13 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
<source>Add Space</source>
<translation>Добавить Пространство</translation>
<source>Add folder sync</source>
<translation>Добавить синхронизацию папки</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="394"/>
<source>You are synchronizing %1 out of %2 Spaces</source>
<translation>Вы синхронизируете %1 из %2 Пространств</translation>
<source>Synchronized folders: %1 of %2</source>
<translation>Синхронизированные папки: %1 из %2</translation>
</message>
</context>
<context>
@@ -602,13 +602,13 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="58"/>
<source>Preparing the account</source>
<translation>Подготовка профиля</translation>
<source>Preparing account</source>
<translation>Подготовка аккаунта</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="86"/>
<source>Sync connections</source>
<translation>Подключения для синхронизации</translation>
<source>Synchronized folders</source>
<translation>Синхронизированные папки</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="204"/>
@@ -623,18 +623,18 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="197"/>
<source>Confirm removal of Space</source>
<translation>Подтвердите удаление Пространства</translation>
<source>Confirm removal of folder sync</source>
<translation>Подтвердите удаление синхронизации папки</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="198"/>
<source>&lt;p&gt;Do you really want to stop syncing the Space »%1«?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will &lt;b&gt;not&lt;/b&gt; delete any files.&lt;/p&gt;</source>
<translation>&lt;p&gt;Вы действительно хотите остановить синхронизацию Пространства »%1«? Примечание:&lt;/b&gt; Это &lt;b&gt;не&lt;/b&gt; приведет к удалению файлов.&lt;/p&gt;</translation>
<source>&lt;p&gt;Stop syncing &lt;b&gt;%1&lt;/b&gt; on this computer?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will not delete files in QSfera or on this device.&lt;/p&gt;</source>
<translation>&lt;p&gt;Остановить синхронизацию &lt;b&gt;%1&lt;/b&gt; на этом компьютере?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Важно:&lt;/b&gt; Файлы в QSfera и на этом устройстве не будут удалены.&lt;/p&gt;</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="203"/>
<source>Remove Space</source>
<translation>Удалить Пространство</translation>
<source>Remove folder sync</source>
<translation>Удалить синхронизацию папки</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="240"/>
@@ -1254,8 +1254,8 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="80"/>
<source>Start on Login</source>
<translation>Запускать при входе в систему</translation>
<source>Start QSfera when I sign in</source>
<translation>Запускать QSfera при входе в систему</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="52"/>
@@ -1269,28 +1269,28 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="98"/>
<source>Sync hidden files</source>
<translation>Синхронизировать скрытые файлы</translation>
<source>Sync hidden files and folders</source>
<translation>Синхронизировать скрытые файлы и папки</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="111"/>
<source>Show crash reporter</source>
<translation>Показать отчет об ошибке</translation>
<source>Show crash reporter after a crash</source>
<translation>Показывать отчет о сбое после ошибки</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="118"/>
<source>Move remotely deleted files to the local trash bin instead of deleting them</source>
<translation>Перемещать файлы, удаленные через серверный доступ, в локальную корзину вместо их полного удаления</translation>
<source>Move server-deleted files to the local trash instead of deleting them</source>
<translation>Перемещать удаленные на сервере файлы в локальную корзину вместо удаления</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="127"/>
<source>Edit Ignored Files</source>
<translation>Изменить игнорируемые файлф</translation>
<source>Ignored files</source>
<translation>Игнорируемые файлы</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="134"/>
<source>Log Settings</source>
<translation>Настройки Логирования</translation>
<source>Logs</source>
<translation>Логи</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="161"/>
@@ -3457,4 +3457,4 @@ Note that using any logging command line options will override the settings.</so
<translation>Некоторые параметры были установлены в более новой версии клиента и используют функции, не доступные в текущей версии</translation>
</message>
</context>
</TS>
</TS>
+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"
+110
View File
@@ -0,0 +1,110 @@
# QSfera Production Readiness
Date: 2026-06-08.
## Benchmarks Used
- Google Photos Android backup: automatic photo/video backup, selected device folders, backup status, mobile-data limits, roaming controls, and battery optimization guidance.
Source: https://support.google.com/photos/answer/6193313?co=GENIE.Platform%3DAndroid&hl=en
- Google Drive Android offline files and sharing roles.
Sources: https://support.google.com/drive/answer/2375012?co=GENIE.Platform%3DAndroid&hl=en and https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DAndroid&hl=en
- Google storage quota behavior across Drive and Photos.
Source: https://support.google.com/drive/answer/9312312?hl=en
- Google Drive for desktop: tray entry, multi-account sign-in, sync status, notifications, offline files, pause/resume sync, adding folders, and stream/mirror model.
Sources: https://support.google.com/drive/answer/10838124?co=GENIE.Platform%3DDesktop&hl=en-fm and https://support.google.com/drive/answer/13401938?hl=en
- Yandex Disk Android auto-upload setup, account choice, network choice, source folders, and uploaded/not-uploaded indicators.
Source: https://yandex.com/support/yandex-360/customers/disk/app/android/en/autoupload/setup
- Yandex Disk mobile link sharing controls: view-only, disable downloads, password, expiration, and organization-only links.
Source: https://yandex.com/support/yandex-360/customers/disk/app/android/en/actions/share
- Yandex Disk desktop: Windows/macOS desktop app, selective sync, auto-save folders, automatic update, autorun, and sleep-prevention behavior during sync.
Sources: https://www.yandex.com/support/yandex-360/customers/disk/web/en/desktop, https://www.yandex.com/support/yandex-360/customers/disk/desktop/windows/en/reserve-copy, and https://yandex.com/support/yandex-360/customers/disk/desktop/windows/en/installation
## 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.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`.
- 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`.
- `Server/scripts/verify-rpi-production.sh` passed on the Pi on 2026-06-09 with `PUBLIC_STATUS_URL=https://qsfera.kusoft.xyz/status.php` and `REQUIRE_METRICS=true`: image `qsfera-cloud-server:7.0.0-rpi5-stable`, container `running`/`healthy`, Docker root `/mnt/data/docker`, root free space `43.0 GiB`, data free space `851.5 GiB`, local and public status `stable`/`7.0.0`, and Prometheus metrics reachable.
- Deployed the Pi systemd monitoring timer on 2026-06-09. `systemd-analyze verify` for the three installed unit files returned exit `0`; `systemctl is-enabled qsfera-rpi-production-check.timer` returned `enabled`; `systemctl is-active qsfera-rpi-production-check.timer` returned `active`; `systemctl show qsfera-rpi-production-check.service` returned `Result=success`, `ExecMainStatus=0`, `ActiveState=inactive`; and `journalctl -u qsfera-rpi-production-check.service` recorded `QSfera Raspberry Pi production verification passed: 0 warning(s).`
## Raspberry Pi 5 Check
- Pi target checked on 2026-06-08: `192.168.0.185` reports `aarch64`, 64-bit Debian 13, Docker `29.4.3`, Docker Compose `v5.1.3`, and Docker server architecture `aarch64`.
- Before stable-image deployment, the old Pi server was running as local/dev image `qsfera-cloud-server:rpi5-local`; public `/status.php` reported `edition: dev` and `productversion: 7.0.0`.
- The Pi server now runs through `/mnt/data/qsfera/cloud-server/src/docker-compose.rpi5.yml` with pinned image `qsfera-cloud-server:7.0.0-rpi5-stable`, bind-mounted config/data under `/mnt/data/qsfera/cloud-server`, loopback backend port `127.0.0.1:9200`, loopback metrics port `127.0.0.1:9205`, and Docker root `/mnt/data/docker`.
- Before switching the Pi server to compose, config/data were backed up to `/mnt/data/qsfera/backups/cloud-server-before-compose-20260608-200921.tar.gz`. The archive was created with `sudo tar`, had size `213M`, and `tar -tzf` completed successfully.
- Before switching to the stable image, config/data were backed up to `/mnt/data/qsfera/backups/cloud-server-before-stable-image-20260608-201536.tar.gz`. The archive had size `213M`, and `tar -tzf` completed successfully.
- `docker run --rm --entrypoint /usr/bin/qsfera qsfera-cloud-server:7.0.0-rpi5-stable version --skip-services` returned `Version: 7.0.0`, `Edition: stable`, and `Compiled: 2026-06-08 00:00:00 +0000 UTC`.
- After deployment, `docker compose ps` reported `qsfera-cloud-server` as `Up` and `healthy`; `docker inspect` reported `IMAGE=qsfera-cloud-server:7.0.0-rpi5-stable HEALTH=healthy`.
- Local `https://127.0.0.1:9200/status.php` and public `https://qsfera.kusoft.xyz/status.php` both returned `edition: stable` and `productversion: 7.0.0`; the public request returned HTTP `200`.
- After Docker builder cache cleanup and the stable deployment, `/dev/mmcblk0p2` reported `57G` size, `12G` used, `43G` available, `21%` use. `/mnt/data` reported `916G` size, `18G` used, `852G` available, `3%` use.
## 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 `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 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.14` cannot be signed with the same certificate.
- Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
- Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values.
- Run `./validate-production-env.sh .env` 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
- 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.
+16
View File
@@ -0,0 +1,16 @@
QSFERA_RPI_IMAGE=qsfera-cloud-server:7.0.0-rpi5-stable
QSFERA_VERSION=7.0.0
QSFERA_EDITION=stable
QSFERA_BUILD_DATE=20260608
QSFERA_RPI_HOST_PORT=9200
QSFERA_RPI_DEBUG_HOST_PORT=9205
QSFERA_RPI_CONFIG_PATH=/mnt/data/qsfera/cloud-server/config
QSFERA_RPI_DATA_PATH=/mnt/data/qsfera/cloud-server/data
OC_URL=https://qsfera.example.com
OC_LOG_LEVEL=info
OC_INSECURE=false
PROXY_TLS=true
PROXY_DEBUG_ADDR=0.0.0.0:9205
PROXY_DEBUG_TOKEN=
FRONTEND_CHECK_FOR_UPDATES=false
+2 -1
View File
@@ -61,8 +61,9 @@ protogen/buf.sha1.lock
go.work
go.work.sum
.env
.env.server
.envrc
.DS_Store
# example deployments
**/qsfera-sandbox-*
**/qsfera-sandbox-*
+11 -2
View File
@@ -26,15 +26,21 @@ RUN make node-generate-prod
FROM golang:1.25-alpine AS build
ARG TARGETARCH=arm64
ARG QSFERA_VERSION=7.0.0
ARG QSFERA_EDITION=stable
ARG QSFERA_BUILD_DATE=20260608
RUN apk add bash make git curl gcc musl-dev libc-dev binutils-gold inotify-tools vips-dev
COPY --from=generate /qsfera /qsfera
WORKDIR /qsfera/qsfera
RUN make release-linux-docker-${TARGETARCH} ENABLE_VIPS=true DIST=/dist
RUN make release-linux-docker-${TARGETARCH} ENABLE_VIPS=true DIST=/dist VERSION="${QSFERA_VERSION}" EDITION="${QSFERA_EDITION}" DATE="${QSFERA_BUILD_DATE}"
FROM alpine:3.23
ARG TARGETARCH=arm64
ARG QSFERA_VERSION=7.0.0
ARG QSFERA_EDITION=stable
ARG QSFERA_BUILD_DATE=20260608
RUN apk add --no-cache attr ca-certificates curl mailcap tree vips && \
echo 'hosts: files dns' >| /etc/nsswitch.conf
@@ -46,7 +52,10 @@ LABEL maintainer="QSfera" \
org.opencontainers.image.description="КуСфера file-sync and share platform" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.documentation="" \
org.opencontainers.image.source=""
org.opencontainers.image.source="" \
org.opencontainers.image.version="${QSFERA_VERSION}" \
eu.qsfera.image.edition="${QSFERA_EDITION}" \
eu.qsfera.image.build-date="${QSFERA_BUILD_DATE}"
ENTRYPOINT ["/usr/bin/qsfera"]
CMD ["server"]
@@ -15,4 +15,6 @@ This deployment example is documented in two locations for different audiences:
Providing details which are more developer focused. This description can also be used when deviating from the default.\
Note that this examples uses self signed certificates and is intended for testing purposes.
For production rollouts from this compose directory, use `env.production.example` as the starting point for `.env`.
It keeps TLS validation enabled, disables demo users, and requires explicit admin, Keycloak, and S3 secrets instead of demo defaults.
Run `./validate-production-env.sh .env` before `docker compose config` and rollout.
@@ -9,6 +9,6 @@ services:
# decomposeds3 specific settings
STORAGE_USERS_DECOMPOSEDS3_ENDPOINT: ${DECOMPOSEDS3_ENDPOINT:-http://minio:9000}
STORAGE_USERS_DECOMPOSEDS3_REGION: ${DECOMPOSEDS3_REGION:-default}
STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key}
STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:-qsfera-bucket}
STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting decomposeds3 in production}
STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting decomposeds3 in production}
STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting decomposeds3 in production}
@@ -0,0 +1,49 @@
## Production-safe qsfera_full environment template.
## Copy to .env and fill every placeholder before running docker compose.
COMPOSE_PATH_SEPARATOR=:
QSFERA=:qsfera.yml
## Public deployment identity.
OC_DOMAIN=<cloud.example.com>
TRAEFIK_ACME_MAIL=<admin@example.com>
## Use the production image and pin a release tag for repeatable rollouts.
OC_DOCKER_IMAGE=qsfera/qsfera
OC_DOCKER_TAG=<release-tag>
## Production must validate TLS certificates and must not create demo users.
INSECURE=false
DEMO_USERS=false
## Required by qsfera.yml.
ADMIN_PASSWORD=<replace-with-strong-password>
## Optional: enable Keycloak-backed production identity.
## Uncomment KEYCLOAK and fill all values before enabling it.
#KEYCLOAK=:keycloak.yml
KEYCLOAK_DOMAIN=<keycloak.example.com>
KEYCLOAK_REALM=qsfera
KEYCLOAK_POSTGRES_PASSWORD=<replace-with-strong-password>
KEYCLOAK_ADMIN_USER=<replace-with-admin-user>
KEYCLOAK_ADMIN_PASSWORD=<replace-with-strong-password>
## Optional: enable S3-backed user storage.
## Uncomment DECOMPOSEDS3 and fill all values before enabling it.
#DECOMPOSEDS3=:decomposeds3.yml
DECOMPOSEDS3_ENDPOINT=<https://s3.example.com>
DECOMPOSEDS3_REGION=<region>
DECOMPOSEDS3_ACCESS_KEY=<replace-with-access-key>
DECOMPOSEDS3_SECRET_KEY=<replace-with-secret-key>
DECOMPOSEDS3_BUCKET=<bucket>
## Optional: local MinIO is intended for test/lab installs, not public production.
#DECOMPOSEDS3_MINIO=:minio.yml
#MINIO_DOMAIN=<minio.example.com>
## Optional: use host paths for backup-friendly config and data storage.
#OC_CONFIG_DIR=/srv/qsfera/config
#OC_DATA_DIR=/srv/qsfera/data
## Keep this last: it assembles the selected compose files.
COMPOSE_FILE=docker-compose.yml${QSFERA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${KEYCLOAK:-}
@@ -33,7 +33,7 @@ services:
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
POSTGRES_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production}
logging:
driver: ${LOG_DRIVER:-local}
restart: always
@@ -53,10 +53,10 @@ services:
KC_DB: postgres
KC_DB_URL: "jdbc:postgresql://postgres:5432/keycloak"
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_DB_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production}
KC_FEATURES: impersonation
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:?Set KEYCLOAK_ADMIN_USER before starting Keycloak in production}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD before starting Keycloak in production}
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.entrypoints=https"
@@ -10,13 +10,13 @@ services:
command:
[
"-c",
"mkdir -p /data/${DECOMPOSEDS3_BUCKET:-qsfera-bucket} && minio server --console-address ':9001' /data",
"mkdir -p /data/${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting MinIO in production} && minio server --console-address ':9001' /data",
]
volumes:
- minio-data:/data
environment:
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key}
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting MinIO in production}
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting MinIO in production}
labels:
- "traefik.enable=true"
- "traefik.http.routers.minio.entrypoints=https"
@@ -33,7 +33,7 @@ services:
# basic auth (not recommended, but needed for eg. WebDav clients that do not support OpenID Connect)
PROXY_ENABLE_BASIC_AUTH: "${PROXY_ENABLE_BASIC_AUTH:-false}"
# admin user password
IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:-admin}" # this overrides the admin password from the configuration file
IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:?Set ADMIN_PASSWORD before starting qsfera_full in production}" # this overrides the admin password from the configuration file
# demo users
IDM_CREATE_DEMO_USERS: "${DEMO_USERS:-false}"
# idp login form settings
@@ -0,0 +1,155 @@
#!/usr/bin/env sh
set -eu
ENV_FILE="${1:-.env}"
errors=0
warnings=0
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: env file not found: $ENV_FILE" >&2
exit 1
fi
value() {
key="$1"
awk -v key="$key" '
/^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
{
line = $0
sub(/\r$/, "", line)
split(line, parts, "=")
current = parts[1]
gsub(/^[ \t]+|[ \t]+$/, "", current)
if (current == key) {
sub(/^[^=]*=/, "", line)
gsub(/^"/, "", line)
gsub(/"$/, "", line)
gsub(/^'\''/, "", line)
gsub(/'\''$/, "", line)
print line
exit
}
}
' "$ENV_FILE"
}
is_placeholder() {
case "${1:-}" in
""|"<"*">"|*example.com*|"change-me"|"changeme"|"password"|"secret")
return 0
;;
*)
return 1
;;
esac
}
error() {
errors=$((errors + 1))
echo "ERROR: $1" >&2
}
warn() {
warnings=$((warnings + 1))
echo "WARN: $1" >&2
}
require_value() {
key="$1"
label="${2:-$1}"
current="$(value "$key")"
if is_placeholder "$current"; then
error "$label must be set to a production value."
fi
}
require_secret() {
key="$1"
current="$(value "$key")"
if is_placeholder "$current"; then
error "$key must be set."
return
fi
case "$current" in
admin|demo|keycloak|qsfera|qsfera-secret-key)
error "$key uses a known demo/default value."
return
;;
esac
if [ "${#current}" -lt 16 ]; then
error "$key must be at least 16 characters."
fi
}
enabled() {
current="$(value "$1")"
[ -n "$current" ]
}
require_value OC_DOMAIN "OC_DOMAIN"
require_value TRAEFIK_ACME_MAIL "TRAEFIK_ACME_MAIL"
require_secret ADMIN_PASSWORD
case "$(value INSECURE)" in
true|TRUE|1|yes|YES)
error "INSECURE must be false for production."
;;
esac
case "$(value DEMO_USERS)" in
true|TRUE|1|yes|YES)
error "DEMO_USERS must be false for production."
;;
esac
case "$(value OC_DOCKER_IMAGE)" in
qsfera/qsfera)
;;
"")
warn "OC_DOCKER_IMAGE is empty; compose default must be verified before rollout."
;;
*)
error "OC_DOCKER_IMAGE must be qsfera/qsfera for production."
;;
esac
case "$(value OC_DOCKER_TAG)" in
""|latest|"<"*">")
error "OC_DOCKER_TAG must pin a concrete production release tag."
;;
esac
if ! enabled QSFERA; then
error "QSFERA=:qsfera.yml must be enabled."
fi
if enabled DECOMPOSEDS3; then
require_value DECOMPOSEDS3_ENDPOINT "DECOMPOSEDS3_ENDPOINT"
require_value DECOMPOSEDS3_REGION "DECOMPOSEDS3_REGION"
require_secret DECOMPOSEDS3_ACCESS_KEY
require_secret DECOMPOSEDS3_SECRET_KEY
require_value DECOMPOSEDS3_BUCKET "DECOMPOSEDS3_BUCKET"
fi
if enabled DECOMPOSEDS3_MINIO; then
error "DECOMPOSEDS3_MINIO is for test/lab installs and must not be enabled for public production."
fi
if enabled KEYCLOAK; then
require_value KEYCLOAK_DOMAIN "KEYCLOAK_DOMAIN"
require_value KEYCLOAK_REALM "KEYCLOAK_REALM"
require_secret KEYCLOAK_POSTGRES_PASSWORD
require_value KEYCLOAK_ADMIN_USER "KEYCLOAK_ADMIN_USER"
require_secret KEYCLOAK_ADMIN_PASSWORD
fi
if [ -z "$(value OC_CONFIG_DIR)" ] || [ -z "$(value OC_DATA_DIR)" ]; then
warn "OC_CONFIG_DIR and OC_DATA_DIR are not both set; docker volumes are harder to back up and restore."
fi
if [ "$errors" -gt 0 ]; then
echo "Production env validation failed: $errors error(s), $warnings warning(s)." >&2
exit 1
fi
echo "Production env validation passed: $warnings warning(s)."
+41
View File
@@ -0,0 +1,41 @@
services:
qsfera-cloud-server:
container_name: qsfera-cloud-server
image: ${QSFERA_RPI_IMAGE:-qsfera-cloud-server:7.0.0-rpi5-stable}
platform: linux/arm64
build:
context: .
dockerfile: Dockerfile
args:
TARGETARCH: arm64
QSFERA_VERSION: ${QSFERA_VERSION:-7.0.0}
QSFERA_EDITION: ${QSFERA_EDITION:-stable}
QSFERA_BUILD_DATE: ${QSFERA_BUILD_DATE:-20260608}
restart: unless-stopped
entrypoint:
- /bin/sh
command:
- -c
- qsfera init || true; exec qsfera server
environment:
OC_LOG_LEVEL: ${OC_LOG_LEVEL:-info}
FRONTEND_CHECK_FOR_UPDATES: ${FRONTEND_CHECK_FOR_UPDATES:-false}
OC_CONFIG_DIR: /etc/qsfera
OC_BASE_DATA_PATH: /var/lib/qsfera
OC_URL: ${OC_URL:?OC_URL must be set in .env}
OC_INSECURE: ${OC_INSECURE:-false}
PROXY_TLS: ${PROXY_TLS:-true}
PROXY_DEBUG_ADDR: ${PROXY_DEBUG_ADDR:-0.0.0.0:9205}
PROXY_DEBUG_TOKEN: ${PROXY_DEBUG_TOKEN:-}
ports:
- "127.0.0.1:${QSFERA_RPI_HOST_PORT:-9200}:9200"
- "127.0.0.1:${QSFERA_RPI_DEBUG_HOST_PORT:-9205}:9205"
volumes:
- ${QSFERA_RPI_CONFIG_PATH:?QSFERA_RPI_CONFIG_PATH must be set in .env}:/etc/qsfera
- ${QSFERA_RPI_DATA_PATH:?QSFERA_RPI_DATA_PATH must be set in .env}:/var/lib/qsfera
healthcheck:
test: ["CMD-SHELL", "curl -kfsS https://127.0.0.1:9200/status.php >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
+292
View File
@@ -0,0 +1,292 @@
# Raspberry Pi 5 Docker Run
This server repository contains a Dockerized QSfera path for Raspberry Pi 5 and other
64-bit ARM Linux hosts.
Files used for this deployment:
- `Dockerfile`
- `docker-compose.rpi5.yml`
- `.env.example`
## What This Runs
- QSfera server from this `Server` source tree.
- A pinned production image tag by default: `qsfera-cloud-server:7.0.0-rpi5-stable`.
- Build metadata injected into the binary through Docker build args:
`QSFERA_VERSION`, `QSFERA_EDITION`, and `QSFERA_BUILD_DATE`.
- QSfera config persisted under `/etc/qsfera` inside the container.
- QSfera data persisted under `/var/lib/qsfera` inside the container.
- Host-side config and data paths controlled by `QSFERA_RPI_CONFIG_PATH` and
`QSFERA_RPI_DATA_PATH`.
- Host-side backend port bound only to `127.0.0.1:9200`; external access should go
through the reverse proxy and HTTPS domain.
- Host-side proxy debug/metrics port bound only to `127.0.0.1:9205`.
## Prerequisites On Raspberry Pi 5
1. Install Docker Engine using the official Docker docs: `https://docs.docker.com/engine/install/debian/`.
2. Install the Docker Compose plugin using the official Docker docs: `https://docs.docker.com/compose/install/`.
3. Use a 64-bit OS. The compose file sets `platform: linux/arm64`, and the Dockerfile builds with `TARGETARCH=arm64`.
4. Put `QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` on durable storage, not on a nearly full SD-card root filesystem.
5. Set `OC_URL` to the public HTTPS URL used by clients and the reverse proxy.
6. Keep `QSFERA_EDITION=stable` and a concrete `QSFERA_VERSION` for production
rollouts. Leaving those unset falls back to the defaults in the compose file.
7. Keep Docker image and builder retention under control. The current Raspberry Pi
deployment stores QSfera data on `/mnt/data`; Docker's own `data-root` must
either be moved there during a maintenance window or pruned on a schedule.
## Start
Run from the `Server` directory:
```bash
cp .env.example .env
docker compose -f docker-compose.rpi5.yml up -d --build
```
Verify the image metadata before rollout:
```bash
docker compose -f docker-compose.rpi5.yml build
docker run --rm --entrypoint /usr/bin/qsfera qsfera-cloud-server:7.0.0-rpi5-stable version --skip-services
```
Run the production checker from the `Server` directory after every rollout:
```bash
PUBLIC_STATUS_URL=https://qsfera.example.com/status.php \
./scripts/verify-rpi-production.sh
```
## Stop
```bash
docker compose -f docker-compose.rpi5.yml down
```
## Logs
```bash
docker compose -f docker-compose.rpi5.yml logs -f qsfera-cloud-server
```
## Update After Pulling New Code
```bash
docker compose -f docker-compose.rpi5.yml up -d --build
```
## Verify
Local backend status endpoint:
```bash
curl -k https://127.0.0.1:9200/status.php
```
External reverse-proxied status endpoint:
```bash
curl https://qsfera.example.com/status.php
```
Local Prometheus metrics endpoint:
```bash
curl http://127.0.0.1:9205/metrics
```
Repeatable production gate from the `Server` directory:
```bash
PUBLIC_STATUS_URL=https://qsfera.example.com/status.php \
REQUIRE_METRICS=true \
./scripts/verify-rpi-production.sh
```
The checker validates:
- Docker Compose config rendering.
- Local presence of the pinned image.
- Image metadata from `qsfera version --skip-services`.
- Container running state, Docker health status, and container image tag.
- Docker root location, root filesystem free space, and data filesystem free space.
- Local and optional public `/status.php` JSON invariants:
`installed=true`, `maintenance=false`, `needsDbUpgrade=false`,
`edition=stable`, and `productversion=7.0.0`.
- Optional Prometheus metrics reachability.
The default free-space thresholds are 5 GiB for `/` and 20 GiB for `/mnt/data`.
Override them with `MIN_ROOT_AVAILABLE_KB` and `MIN_DATA_AVAILABLE_KB` when the
host has a documented capacity plan.
## Data Location
Docker bind mounts:
- host config: `${QSFERA_RPI_CONFIG_PATH}`
- container config: `/etc/qsfera`
- host data: `${QSFERA_RPI_DATA_PATH}`
- container data: `/var/lib/qsfera`
For the current Raspberry Pi deployment checked on 2026-06-09 after the metrics
rollout, the existing container uses:
- host config: `/mnt/data/qsfera/cloud-server/config`
- host data: `/mnt/data/qsfera/cloud-server/data`
- image: `qsfera-cloud-server:7.0.0-rpi5-stable`
- public URL: `https://qsfera.kusoft.xyz`
- loopback port: `127.0.0.1:9200`
- loopback metrics port: `127.0.0.1:9205`
- Docker data-root: `/mnt/data/docker`
## Reverse Proxy
The server is intentionally bound to loopback. Put Caddy, Nginx, Traefik, or another
reverse proxy in front of it for external HTTPS access.
The inspected Raspberry Pi uses Caddy with this QSfera route:
```caddyfile
qsfera.kusoft.xyz {
encode zstd gzip
handle {
reverse_proxy https://127.0.0.1:9200 {
transport http {
tls_insecure_skip_verify
versions 1.1
}
}
}
}
```
Do not proxy `127.0.0.1:9205` to the public Internet. If a remote Prometheus
server needs to scrape it, connect through a private tunnel or bind it to an
internal-only interface and set `PROXY_DEBUG_TOKEN`.
## Monitoring And Alerts
Install the production checker timer from the `Server` directory:
```bash
sudo ./scripts/install-rpi-production-monitoring.sh
```
The installer writes:
- `/etc/systemd/system/qsfera-rpi-production-check.service`
- `/etc/systemd/system/qsfera-rpi-production-check.timer`
- `/etc/systemd/system/qsfera-rpi-production-check-alert@.service`
- `/etc/qsfera/rpi-production-check.env`
The timer runs `./scripts/verify-rpi-production.sh` every 5 minutes. The service
uses `OnFailure=qsfera-rpi-production-check-alert@%n.service`; the alert service
logs the failed unit and recent journal output through `systemd-cat`. To forward
alerts off-host, set `QSFERA_ALERT_WEBHOOK_URL` in
`/etc/qsfera/rpi-production-check.env`; `alert-rpi-production-check-failure.sh`
sends a JSON `POST` when that variable is set.
For the current Raspberry Pi deployment checked on 2026-06-09, the timer was
installed under `/etc/systemd/system`, `systemctl is-enabled` returned
`enabled`, `systemctl is-active` returned `active`, and the checker service
reported `Result=success`, `ExecMainStatus=0`, and `ActiveState=inactive` after
the initial run. `systemctl list-timers qsfera-rpi-production-check.timer`
reported the next run 5 minutes after the previous run.
Verify the timer:
```bash
systemctl list-timers qsfera-rpi-production-check.timer --no-pager
systemctl status qsfera-rpi-production-check.timer --no-pager
systemctl show qsfera-rpi-production-check.service -p Result -p ExecMainStatus -p ActiveState
journalctl -u qsfera-rpi-production-check.service -n 80 --no-pager
```
Minimum monitoring expectations:
- Keep `qsfera-rpi-production-check.timer` enabled and active.
- Investigate any non-zero `ExecMainStatus` from
`qsfera-rpi-production-check.service`.
- Investigate any `WARN:` line that persists for more than one day, especially
Docker root outside `/mnt/data` or unavailable metrics.
- Configure `QSFERA_ALERT_WEBHOOK_URL` when off-host alert delivery is required;
without it, failures are still routed to the systemd failure service and
journal, but not to an external notification endpoint.
- Scrape `http://127.0.0.1:9205/metrics` locally or through a private tunnel.
- Keep public HTTPS probing separate from local loopback probing so reverse-proxy
and backend failures are distinguishable.
- Keep `OC_LOG_LEVEL=info` for production; raise to `debug` only for a bounded
incident window.
For larger deployments, the full compose example already contains tracing and
metrics wiring in `devtools/deployments/qsfera_full/monitoring_tracing/`.
## Backups And Restore Drill
Back up the two bind-mounted paths from `.env`:
- `QSFERA_RPI_CONFIG_PATH`
- `QSFERA_RPI_DATA_PATH`
Create an archive before every server image rollout:
```bash
stamp="$(date -u +%Y%m%d-%H%M%S)"
backup="/mnt/data/qsfera/backups/cloud-server-${stamp}.tar.gz"
sudo tar -C /mnt/data/qsfera/cloud-server -czf "$backup" config data
sudo tar -tzf "$backup" >/dev/null
```
Run a restore drill after backup automation changes and at least monthly:
```bash
stamp="$(date -u +%Y%m%d-%H%M%S)"
restore_root="/mnt/data/qsfera/restore-drills/${stamp}"
sudo mkdir -p "$restore_root"
sudo tar -C "$restore_root" -xzf "$backup"
sudo test -d "$restore_root/config"
sudo test -d "$restore_root/data"
```
For a full service restore test, copy `.env` to a drill-only file, point
`QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` at the extracted directories,
bind `QSFERA_RPI_HOST_PORT` to an unused loopback port, run
`docker compose -f docker-compose.rpi5.yml --env-file .env.restore config`, and
only then start the drill container. Do not run a restore drill against the live
config/data paths.
## Docker Data-Root And Retention
Check where Docker stores images, layers, and build cache:
```bash
docker info --format '{{.DockerRootDir}}'
docker system df
```
Preferred production state on this Pi is Docker data-root under `/mnt/data`,
because the QSfera bind-mounted data is already stored there. Move it only during
a maintenance window:
```bash
sudo systemctl stop docker
sudo mkdir -p /mnt/data/docker
sudo rsync -aHAX --numeric-ids /var/lib/docker/ /mnt/data/docker/
printf '{\n "data-root": "/mnt/data/docker"\n}\n' | sudo tee /etc/docker/daemon.json
sudo systemctl start docker
docker info --format '{{.DockerRootDir}}'
docker compose -f docker-compose.rpi5.yml ps
```
If moving Docker data-root is postponed, keep conservative retention active:
```bash
docker builder prune --filter 'until=168h'
docker image prune --filter 'until=168h'
docker system df
```
Do not run `docker system prune --volumes` on this host unless a current backup
has been verified and the target volumes have been explicitly reviewed.
@@ -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.
@@ -0,0 +1,56 @@
#!/usr/bin/env sh
set -eu
FAILED_UNIT="${1:-qsfera-rpi-production-check.service}"
JOURNAL_LINES="${QSFERA_ALERT_JOURNAL_LINES:-80}"
HOSTNAME_VALUE="$(hostname -f 2>/dev/null || hostname)"
TIMESTAMP_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
SUMMARY="QSfera Raspberry Pi production check failed on ${HOSTNAME_VALUE}: ${FAILED_UNIT} at ${TIMESTAMP_UTC}"
if command -v journalctl >/dev/null 2>&1; then
JOURNAL_OUTPUT="$(journalctl -u "$FAILED_UNIT" -n "$JOURNAL_LINES" --no-pager 2>/dev/null || true)"
else
JOURNAL_OUTPUT=""
fi
{
echo "$SUMMARY"
if [ -n "$JOURNAL_OUTPUT" ]; then
echo
echo "$JOURNAL_OUTPUT"
fi
} | if command -v systemd-cat >/dev/null 2>&1; then
systemd-cat -t qsfera-rpi-production-alert -p err
else
cat >&2
fi
if [ -n "${QSFERA_ALERT_WEBHOOK_URL:-}" ]; then
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 is required to format the alert webhook payload." >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "ERROR: curl is required to send the alert webhook." >&2
exit 1
fi
python3 - "$SUMMARY" "$FAILED_UNIT" "$HOSTNAME_VALUE" "$TIMESTAMP_UTC" "$JOURNAL_OUTPUT" <<'PY' |
import json
import sys
summary, failed_unit, hostname, timestamp_utc, journal = sys.argv[1:]
print(json.dumps({
"summary": summary,
"failedUnit": failed_unit,
"host": hostname,
"timestampUtc": timestamp_utc,
"journal": journal,
}, ensure_ascii=False))
PY
curl -fsS \
-X POST \
-H "Content-Type: application/json" \
--data-binary @- \
"$QSFERA_ALERT_WEBHOOK_URL" >/dev/null
fi
@@ -0,0 +1,62 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
SERVER_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
ENV_DIR="${ENV_DIR:-/etc/qsfera}"
ENV_FILE="${ENV_FILE:-$ENV_DIR/rpi-production-check.env}"
RUN_NOW="${RUN_NOW:-true}"
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
else
SUDO="${SUDO:-sudo}"
fi
run_privileged() {
if [ -n "$SUDO" ]; then
"$SUDO" "$@"
else
"$@"
fi
}
install_unit() {
source_path="$1"
target_path="$2"
run_privileged install -m 0644 "$source_path" "$target_path"
}
tmp_env="$(mktemp)"
cleanup() {
rm -f "$tmp_env"
}
trap cleanup EXIT INT TERM
run_privileged install -d -m 0755 "$SYSTEMD_DIR" "$ENV_DIR"
if [ ! -f "$ENV_FILE" ]; then
{
echo "QSFERA_SERVER_DIR=$SERVER_DIR"
grep -v '^QSFERA_SERVER_DIR=' "$SERVER_DIR/systemd/rpi-production-check.env.example"
} >"$tmp_env"
run_privileged install -m 0640 "$tmp_env" "$ENV_FILE"
echo "Created $ENV_FILE"
else
echo "Keeping existing $ENV_FILE"
fi
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check.service" "$SYSTEMD_DIR/qsfera-rpi-production-check.service"
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check.timer" "$SYSTEMD_DIR/qsfera-rpi-production-check.timer"
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check-alert@.service" "$SYSTEMD_DIR/qsfera-rpi-production-check-alert@.service"
run_privileged systemctl daemon-reload
run_privileged systemctl enable --now qsfera-rpi-production-check.timer
if [ "$RUN_NOW" = "true" ]; then
run_privileged systemctl start qsfera-rpi-production-check.service
fi
run_privileged systemctl --no-pager status qsfera-rpi-production-check.timer
run_privileged systemctl --no-pager show qsfera-rpi-production-check.service -p Result -p ExecMainStatus -p ActiveState
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env sh
set -eu
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.rpi5.yml}"
ENV_FILE="${ENV_FILE:-.env}"
SERVICE="${SERVICE:-qsfera-cloud-server}"
CONTAINER="${CONTAINER:-qsfera-cloud-server}"
EXPECTED_IMAGE="${EXPECTED_IMAGE:-qsfera-cloud-server:7.0.0-rpi5-stable}"
EXPECTED_VERSION="${EXPECTED_VERSION:-7.0.0}"
EXPECTED_EDITION="${EXPECTED_EDITION:-stable}"
LOCAL_STATUS_URL="${LOCAL_STATUS_URL:-https://127.0.0.1:9200/status.php}"
PUBLIC_STATUS_URL="${PUBLIC_STATUS_URL:-}"
METRICS_URL="${METRICS_URL:-http://127.0.0.1:9205/metrics}"
REQUIRE_METRICS="${REQUIRE_METRICS:-false}"
VERIFY_IMAGE_VERSION="${VERIFY_IMAGE_VERSION:-true}"
ROOT_PATH="${ROOT_PATH:-/}"
DATA_PATH="${DATA_PATH:-/mnt/data}"
MIN_ROOT_AVAILABLE_KB="${MIN_ROOT_AVAILABLE_KB:-5242880}"
MIN_DATA_AVAILABLE_KB="${MIN_DATA_AVAILABLE_KB:-20971520}"
EXPECTED_DOCKER_ROOT_PREFIX="${EXPECTED_DOCKER_ROOT_PREFIX:-/mnt/data}"
STRICT_DOCKER_ROOT="${STRICT_DOCKER_ROOT:-false}"
errors=0
warnings=0
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT INT TERM
ok() {
echo "OK: $*"
}
warn() {
warnings=$((warnings + 1))
echo "WARN: $*" >&2
}
error() {
errors=$((errors + 1))
echo "ERROR: $*" >&2
}
is_true() {
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
true|1|yes|on)
return 0
;;
*)
return 1
;;
esac
}
require_command() {
if command -v "$1" >/dev/null 2>&1; then
ok "Command available: $1"
else
error "Required command is missing: $1"
fi
}
compose() {
if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
else
docker compose -f "$COMPOSE_FILE" "$@"
fi
}
available_kb() {
df -Pk "$1" 2>/dev/null | awk 'NR == 2 { print $4 }'
}
format_gib() {
awk -v kb="$1" 'BEGIN { printf "%.1f", kb / 1048576 }'
}
check_disk() {
path="$1"
minimum_kb="$2"
label="$3"
current_kb="$(available_kb "$path")"
if [ -z "$current_kb" ]; then
error "Cannot read free disk space for $label at $path."
return
fi
if [ "$current_kb" -ge "$minimum_kb" ]; then
ok "$label free space $(format_gib "$current_kb") GiB >= $(format_gib "$minimum_kb") GiB"
else
error "$label free space $(format_gib "$current_kb") GiB < $(format_gib "$minimum_kb") GiB"
fi
}
check_status_url() {
label="$1"
url="$2"
curl_opts="$3"
output="$tmp_dir/status.json"
if curl $curl_opts "$url" -o "$output"; then
ok "$label returned a successful HTTP response"
else
error "$label request failed: $url"
return
fi
if python3 - "$output" "$EXPECTED_EDITION" "$EXPECTED_VERSION" "$label" <<'PY'
import json
import sys
path, expected_edition, expected_version, label = sys.argv[1:]
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
checks = [
("installed", payload.get("installed") is True, True, payload.get("installed")),
("maintenance", payload.get("maintenance") is False, False, payload.get("maintenance")),
("needsDbUpgrade", payload.get("needsDbUpgrade") is False, False, payload.get("needsDbUpgrade")),
("edition", payload.get("edition") == expected_edition, expected_edition, payload.get("edition")),
("productversion", payload.get("productversion") == expected_version, expected_version, payload.get("productversion")),
]
failed = False
for name, passed, expected, actual in checks:
if passed:
print(f"OK: {label} {name} = {actual}")
else:
print(f"ERROR: {label} {name}: expected {expected!r}, got {actual!r}", file=sys.stderr)
failed = True
if failed:
sys.exit(1)
PY
then
ok "$label JSON invariants match expected production state"
else
error "$label JSON invariants failed"
fi
}
check_metrics() {
output="$tmp_dir/metrics.txt"
if [ -n "${METRICS_BEARER_TOKEN:-}" ]; then
metrics_result=0
curl -fsS -H "Authorization: Bearer ${METRICS_BEARER_TOKEN}" "$METRICS_URL" -o "$output" || metrics_result=$?
else
metrics_result=0
curl -fsS "$METRICS_URL" -o "$output" || metrics_result=$?
fi
if [ "$metrics_result" -eq 0 ]; then
if grep -Eq '^(# HELP|# TYPE|go_|process_)' "$output"; then
ok "Metrics endpoint returned Prometheus-formatted content"
else
error "Metrics endpoint responded, but content does not look like Prometheus metrics."
fi
return
fi
if is_true "$REQUIRE_METRICS"; then
error "Metrics endpoint is required but unavailable: $METRICS_URL"
else
warn "Metrics endpoint unavailable: $METRICS_URL"
fi
}
for command_name in docker curl python3 awk df; do
require_command "$command_name"
done
if [ ! -f "$COMPOSE_FILE" ]; then
error "Compose file not found: $COMPOSE_FILE"
fi
if [ -n "$ENV_FILE" ] && [ ! -f "$ENV_FILE" ]; then
error "Environment file not found: $ENV_FILE"
fi
if [ "$errors" -eq 0 ]; then
if compose config >/dev/null; then
ok "Docker Compose config renders successfully for $COMPOSE_FILE"
else
error "Docker Compose config failed for $COMPOSE_FILE"
fi
fi
if docker image inspect "$EXPECTED_IMAGE" >/dev/null 2>&1; then
ok "Expected image exists locally: $EXPECTED_IMAGE"
else
error "Expected image is missing locally: $EXPECTED_IMAGE"
fi
if is_true "$VERIFY_IMAGE_VERSION"; then
version_output="$tmp_dir/image-version.txt"
if docker run --rm --entrypoint /usr/bin/qsfera "$EXPECTED_IMAGE" version --skip-services >"$version_output" 2>&1; then
if grep -q "Version: $EXPECTED_VERSION" "$version_output" && grep -q "Edition: $EXPECTED_EDITION" "$version_output"; then
ok "Image version metadata matches $EXPECTED_EDITION $EXPECTED_VERSION"
else
error "Image version metadata does not match expected $EXPECTED_EDITION $EXPECTED_VERSION."
sed 's/^/ /' "$version_output" >&2
fi
else
error "Cannot read image version metadata from $EXPECTED_IMAGE."
sed 's/^/ /' "$version_output" >&2
fi
fi
container_status="$(docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>/dev/null || true)"
if [ "$container_status" = "running" ]; then
ok "Container $CONTAINER is running"
else
error "Container $CONTAINER status is '${container_status:-missing}', expected running."
fi
container_health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CONTAINER" 2>/dev/null || true)"
case "$container_health" in
healthy)
ok "Container $CONTAINER health is healthy"
;;
none)
warn "Container $CONTAINER has no Docker healthcheck result."
;;
*)
error "Container $CONTAINER health is '${container_health:-missing}', expected healthy."
;;
esac
container_image="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER" 2>/dev/null || true)"
if [ "$container_image" = "$EXPECTED_IMAGE" ]; then
ok "Container image is $EXPECTED_IMAGE"
else
error "Container image is '${container_image:-missing}', expected $EXPECTED_IMAGE."
fi
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
case "$docker_root" in
"$EXPECTED_DOCKER_ROOT_PREFIX"|"$EXPECTED_DOCKER_ROOT_PREFIX"/*)
ok "Docker root is under $EXPECTED_DOCKER_ROOT_PREFIX: $docker_root"
;;
"")
error "Cannot read Docker root directory."
;;
*)
if is_true "$STRICT_DOCKER_ROOT"; then
error "Docker root is $docker_root, expected path under $EXPECTED_DOCKER_ROOT_PREFIX."
else
warn "Docker root is $docker_root, not under $EXPECTED_DOCKER_ROOT_PREFIX; keep image/build retention active or move data-root during maintenance."
fi
;;
esac
check_disk "$ROOT_PATH" "$MIN_ROOT_AVAILABLE_KB" "Root filesystem"
check_disk "$DATA_PATH" "$MIN_DATA_AVAILABLE_KB" "Data filesystem"
check_status_url "Local server status" "$LOCAL_STATUS_URL" "-kfsS"
if [ -n "$PUBLIC_STATUS_URL" ]; then
check_status_url "Public server status" "$PUBLIC_STATUS_URL" "-fsS"
fi
check_metrics
if [ "$errors" -gt 0 ]; then
echo "QSfera Raspberry Pi production verification failed: $errors error(s), $warnings warning(s)." >&2
exit 1
fi
echo "QSfera Raspberry Pi production verification passed: $warnings warning(s)."
@@ -0,0 +1,8 @@
[Unit]
Description=Report QSfera Raspberry Pi production check failure for %I
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
[Service]
Type=oneshot
EnvironmentFile=-/etc/qsfera/rpi-production-check.env
ExecStart=/bin/sh -c 'cd "${QSFERA_SERVER_DIR:-/mnt/data/qsfera/cloud-server/src}" && exec ./scripts/alert-rpi-production-check-failure.sh "$1"' -- "%I"
@@ -0,0 +1,12 @@
[Unit]
Description=QSfera Raspberry Pi production verification
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
Wants=network-online.target
After=network-online.target docker.service
Requires=docker.service
OnFailure=qsfera-rpi-production-check-alert@%n.service
[Service]
Type=oneshot
EnvironmentFile=-/etc/qsfera/rpi-production-check.env
ExecStart=/bin/sh -c 'cd "${QSFERA_SERVER_DIR:-/mnt/data/qsfera/cloud-server/src}" && exec ./scripts/verify-rpi-production.sh'
@@ -0,0 +1,13 @@
[Unit]
Description=Run QSfera Raspberry Pi production verification every five minutes
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
Unit=qsfera-rpi-production-check.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,15 @@
QSFERA_SERVER_DIR=/mnt/data/qsfera/cloud-server/src
PUBLIC_STATUS_URL=https://qsfera.kusoft.xyz/status.php
REQUIRE_METRICS=true
METRICS_URL=http://127.0.0.1:9205/metrics
EXPECTED_IMAGE=qsfera-cloud-server:7.0.0-rpi5-stable
EXPECTED_VERSION=7.0.0
EXPECTED_EDITION=stable
EXPECTED_DOCKER_ROOT_PREFIX=/mnt/data
STRICT_DOCKER_ROOT=true
MIN_ROOT_AVAILABLE_KB=5242880
MIN_DATA_AVAILABLE_KB=20971520
QSFERA_ALERT_JOURNAL_LINES=80
# Optional off-host alert endpoint. The alert script sends a JSON POST when set.
#QSFERA_ALERT_WEBHOOK_URL=https://alerts.example.com/qsfera-rpi
+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."

Some files were not shown because too many files have changed in this diff Show More