Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39d65fcede | |||
| cdd442ebf7 | |||
| 0c5f178c8e | |||
| 9ad7f0abdb | |||
| b9a0c26b0b | |||
| 9fad6b1595 | |||
| 709a38cae1 | |||
| 36f052223b | |||
| 2607c7d08e | |||
| da19575477 | |||
| 688c0a3b19 | |||
| 251391b1a9 | |||
| 32f7c59bd4 | |||
| c247af04a1 | |||
| dfe31ac68b | |||
| 439cf82893 | |||
| b3016e6c99 | |||
| 89a8f32649 | |||
| abdbf47789 | |||
| d79f4b93d0 | |||
| f1cbf65a3c | |||
| 371f94b669 | |||
| 73bb8a62bd | |||
| 1a76143c2e | |||
| 78a13fe148 | |||
| 0acda06d5e | |||
| 4f69012249 | |||
| 0961491d52 | |||
| 0ee974fa38 | |||
| 8f61d0ba8c | |||
| d64957dfcd | |||
| 8337bcb742 | |||
| c1c72900e5 | |||
| 89f3cd0934 | |||
| 4bf54f7fda | |||
| ccd20d76e7 | |||
| 9323f976d8 | |||
| 5e4dfc6642 | |||
| fa47f9b32e | |||
| ccc6bda044 | |||
| cf366b2699 | |||
| c9c4b1b17d | |||
| ac618d400b | |||
| 955be5134d | |||
| e9aa9f8315 | |||
| 458831c83c | |||
| 43caef7a6a |
@@ -0,0 +1 @@
|
|||||||
|
artifacts/
|
||||||
@@ -12,6 +12,13 @@
|
|||||||
# Local configuration files (sdk path, etc)
|
# Local configuration files (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
# Local Argus release signing secrets
|
||||||
|
argus-signing.local.properties
|
||||||
|
signing/
|
||||||
|
|
||||||
|
# Local temporary release scratch files
|
||||||
|
.codex-temp/
|
||||||
|
|
||||||
# Mac .DS_Store files
|
# Mac .DS_Store files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -14,6 +14,18 @@ def envValue(String primary, String legacy = null) {
|
|||||||
return legacy ? System.getenv(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 {
|
dependencies {
|
||||||
// Data and domain modules
|
// Data and domain modules
|
||||||
implementation project(':qsferaDomain')
|
implementation project(':qsferaDomain')
|
||||||
@@ -123,8 +135,8 @@ android {
|
|||||||
|
|
||||||
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
|
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
|
||||||
|
|
||||||
versionCode = 32
|
versionCode = 42
|
||||||
versionName = "1.3.4"
|
versionName = "1.3.14"
|
||||||
|
|
||||||
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
|
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
|
||||||
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
|
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
|
||||||
@@ -235,6 +247,20 @@ android {
|
|||||||
testNamespace "eu.qsfera.android.test"
|
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
|
// Updates output file names of a given variant to format
|
||||||
// [appName].[variant.versionName].[QSFERA_BUILD_NUMBER]-[variant.name].apk.
|
// [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_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_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_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_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_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_PICTURE_UPLOADS_PATH = "picture_uploads_path";
|
||||||
public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_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";
|
public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour";
|
||||||
|
|||||||
+60
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+338
@@ -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,
|
||||||
|
}
|
||||||
+111
-23
@@ -31,6 +31,7 @@ import android.provider.DocumentsContract
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -41,20 +42,22 @@ import androidx.preference.PreferenceFragmentCompat
|
|||||||
import androidx.preference.SwitchPreferenceCompat
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
import eu.qsfera.android.R
|
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_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_BEHAVIOUR
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY
|
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_ENABLED
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC
|
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_PATH
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_SOURCE
|
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.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
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.showAlertDialog
|
||||||
import eu.qsfera.android.extensions.showMessageInSnackbar
|
import eu.qsfera.android.extensions.showMessageInSnackbar
|
||||||
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
|
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
|
||||||
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
||||||
import eu.qsfera.android.utils.DisplayUtils
|
import eu.qsfera.android.utils.DisplayUtils
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -65,9 +68,12 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
private val picturesViewModel by viewModel<SettingsPictureUploadsViewModel>()
|
private val picturesViewModel by viewModel<SettingsPictureUploadsViewModel>()
|
||||||
private val manageAccountsViewModel by viewModel<ManageAccountsViewModel>()
|
private val manageAccountsViewModel by viewModel<ManageAccountsViewModel>()
|
||||||
|
|
||||||
|
private var prefPictureUploadsStatus: Preference? = null
|
||||||
private var prefEnablePictureUploads: SwitchPreferenceCompat? = null
|
private var prefEnablePictureUploads: SwitchPreferenceCompat? = null
|
||||||
private var prefPictureUploadsPath: Preference? = null
|
private var prefPictureUploadsPath: Preference? = null
|
||||||
private var prefPictureUploadsOnWifi: CheckBoxPreference? = null
|
private var prefPictureUploadsOnWifi: CheckBoxPreference? = null
|
||||||
|
private var prefPictureUploadsAllowRoaming: CheckBoxPreference? = null
|
||||||
|
private var prefPictureUploadsMobileDataLimit: ListPreference? = null
|
||||||
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
|
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
|
||||||
private var prefPictureUploadsSourcePath: Preference? = null
|
private var prefPictureUploadsSourcePath: Preference? = null
|
||||||
private var prefPictureUploadsClearSourcePaths: Preference? = null
|
private var prefPictureUploadsClearSourcePaths: Preference? = null
|
||||||
@@ -97,9 +103,12 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.settings_picture_uploads, rootKey)
|
setPreferencesFromResource(R.xml.settings_picture_uploads, rootKey)
|
||||||
|
|
||||||
|
prefPictureUploadsStatus = findPreference(PREF_PICTURE_UPLOADS_STATUS)
|
||||||
prefEnablePictureUploads = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ENABLED)
|
prefEnablePictureUploads = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ENABLED)
|
||||||
prefPictureUploadsPath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH)
|
prefPictureUploadsPath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH)
|
||||||
prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY)
|
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)
|
prefPictureUploadsOnCharging = findPreference(PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY)
|
||||||
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
|
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
|
||||||
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
|
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
|
||||||
@@ -114,7 +123,9 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
|
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
|
||||||
|
|
||||||
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
|
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()
|
initPreferenceListeners()
|
||||||
}
|
}
|
||||||
@@ -128,7 +139,13 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
private fun initStateObservers() {
|
private fun initStateObservers() {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
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 }
|
val availableAccounts = listUserQuotas.filter { it.available != -4L }
|
||||||
prefPictureUploadsAccount?.apply {
|
prefPictureUploadsAccount?.apply {
|
||||||
entries = availableAccounts.map { it.accountName }.toTypedArray()
|
entries = availableAccounts.map { it.accountName }.toTypedArray()
|
||||||
@@ -140,7 +157,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
|
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
|
||||||
} else {
|
} else {
|
||||||
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
|
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
|
||||||
currentAccount?.let {
|
if (currentAccount != null) {
|
||||||
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
|
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
|
||||||
availableAccounts.first().accountName
|
availableAccounts.first().accountName
|
||||||
} else {
|
} else {
|
||||||
@@ -148,18 +165,39 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picturesViewModel.pictureUploads.collect { pictureUploadsConfiguration ->
|
|
||||||
enablePictureUploads(pictureUploadsConfiguration != null, false)
|
enablePictureUploads(pictureUploadsConfiguration != null, false)
|
||||||
pictureUploadsConfiguration?.let {
|
pictureUploadsConfiguration?.let {
|
||||||
prefPictureUploadsAccount?.value = it.accountName
|
prefPictureUploadsAccount?.value = it.accountName
|
||||||
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
|
prefPictureUploadsPath?.summary = picturesViewModel.getUploadPathString()
|
||||||
val sourcePaths = picturesViewModel.getPictureUploadsSourcePaths()
|
val sourcePaths = it.sourcePaths
|
||||||
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(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()
|
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||||
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
|
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
|
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
|
||||||
prefPictureUploadsBehaviour?.value = it.behavior.name
|
prefPictureUploadsBehaviour?.value = it.behavior.name
|
||||||
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
|
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
|
||||||
spaceId = it.spaceId
|
spaceId = it.spaceId
|
||||||
} ?: resetFields()
|
} ?: resetFields()
|
||||||
}
|
}
|
||||||
@@ -167,7 +205,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun initPreferenceListeners() {
|
private fun initPreferenceListeners() {
|
||||||
prefEnablePictureUploads?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
|
prefEnablePictureUploads?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
|
||||||
@@ -241,26 +278,40 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
|
prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as Boolean
|
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
|
||||||
picturesViewModel.useWifiOnly(newValue)
|
picturesViewModel.useWifiOnly(value)
|
||||||
newValue
|
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 ->
|
prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as Boolean
|
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
|
||||||
picturesViewModel.useChargingOnly(newValue)
|
picturesViewModel.useChargingOnly(value)
|
||||||
newValue
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
|
prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as String
|
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
|
||||||
picturesViewModel.handleSelectAccount(newValue)
|
picturesViewModel.handleSelectAccount(value)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
|
prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as String
|
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
|
||||||
picturesViewModel.handleSelectBehaviour(newValue)
|
picturesViewModel.handleSelectBehaviour(value)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,6 +328,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
prefPictureUploadsPath?.isEnabled = value
|
prefPictureUploadsPath?.isEnabled = value
|
||||||
prefPictureUploadsOnWifi?.isEnabled = value
|
prefPictureUploadsOnWifi?.isEnabled = value
|
||||||
|
prefPictureUploadsAllowRoaming?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
|
||||||
|
prefPictureUploadsMobileDataLimit?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
|
||||||
prefPictureUploadsOnCharging?.isEnabled = value
|
prefPictureUploadsOnCharging?.isEnabled = value
|
||||||
prefPictureUploadsSourcePath?.isEnabled = value
|
prefPictureUploadsSourcePath?.isEnabled = value
|
||||||
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
|
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
|
||||||
@@ -286,26 +339,61 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resetFields() {
|
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
|
prefPictureUploadsAccount?.value = null
|
||||||
prefPictureUploadsPath?.summary = null
|
prefPictureUploadsPath?.summary = null
|
||||||
prefPictureUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
|
prefPictureUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||||
prefPictureUploadsClearSourcePaths?.isEnabled = false
|
prefPictureUploadsClearSourcePaths?.isEnabled = false
|
||||||
prefPictureUploadsOnWifi?.isChecked = false
|
prefPictureUploadsOnWifi?.isChecked = false
|
||||||
|
prefPictureUploadsAllowRoaming?.isChecked = false
|
||||||
|
prefPictureUploadsMobileDataLimit?.value = "0"
|
||||||
prefPictureUploadsOnCharging?.isChecked = false
|
prefPictureUploadsOnCharging?.isChecked = false
|
||||||
prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name
|
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 =
|
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
|
||||||
if (sourcePaths.isEmpty()) {
|
if (sourcePathLabels.isEmpty()) {
|
||||||
getString(R.string.prefs_camera_upload_source_paths_empty)
|
getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||||
} else {
|
} else {
|
||||||
sourcePaths.joinToString(separator = "\n") { sourcePath ->
|
sourcePathLabels.values.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
|
||||||
|
sourcePaths.associateWith { sourcePath ->
|
||||||
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
|
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 {
|
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"
|
private const val PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS = "picture_uploads_clear_source_paths"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-5
@@ -30,6 +30,8 @@ import eu.qsfera.android.R
|
|||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
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
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
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.FolderBackUpConfiguration.Companion.pictureUploadsName
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
|
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.model.OCSpace
|
||||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
||||||
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
|
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.AccountProvider
|
||||||
import eu.qsfera.android.providers.ContextProvider
|
import eu.qsfera.android.providers.ContextProvider
|
||||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||||
import eu.qsfera.android.providers.WorkManagerProvider
|
import eu.qsfera.android.providers.WorkManagerProvider
|
||||||
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -57,6 +64,7 @@ class SettingsPictureUploadsViewModel(
|
|||||||
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
|
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
|
||||||
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
||||||
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
|
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
|
||||||
|
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
|
||||||
private val workManagerProvider: WorkManagerProvider,
|
private val workManagerProvider: WorkManagerProvider,
|
||||||
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
||||||
private val contextProvider: ContextProvider,
|
private val contextProvider: ContextProvider,
|
||||||
@@ -67,6 +75,20 @@ class SettingsPictureUploadsViewModel(
|
|||||||
|
|
||||||
private var pictureUploadsSpace: OCSpace? = null
|
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 {
|
init {
|
||||||
initPictureUploads()
|
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) {
|
fun useChargingOnly(chargingOnly: Boolean) {
|
||||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
savePictureUploadsConfigurationUseCase(
|
savePictureUploadsConfigurationUseCase(
|
||||||
@@ -175,13 +217,22 @@ class SettingsPictureUploadsViewModel(
|
|||||||
val previousSourcePaths = getPictureUploadsSourcePaths()
|
val previousSourcePaths = getPictureUploadsSourcePaths()
|
||||||
val newSourcePath = contentUriForTree.toString()
|
val newSourcePath = contentUriForTree.toString()
|
||||||
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
|
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) {
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
savePictureUploadsConfigurationUseCase(
|
savePictureUploadsConfigurationUseCase(
|
||||||
SavePictureUploadsConfigurationUseCase.Params(
|
SavePictureUploadsConfigurationUseCase.Params(
|
||||||
composePictureUploadsConfiguration(
|
composePictureUploadsConfiguration(
|
||||||
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
||||||
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
|
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -194,6 +245,7 @@ class SettingsPictureUploadsViewModel(
|
|||||||
SavePictureUploadsConfigurationUseCase.Params(
|
SavePictureUploadsConfigurationUseCase.Params(
|
||||||
composePictureUploadsConfiguration(
|
composePictureUploadsConfiguration(
|
||||||
sourcePath = "",
|
sourcePath = "",
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -210,8 +262,13 @@ class SettingsPictureUploadsViewModel(
|
|||||||
accountName: String? = _pictureUploads.value?.accountName,
|
accountName: String? = _pictureUploads.value?.accountName,
|
||||||
uploadPath: String? = _pictureUploads.value?.uploadPath,
|
uploadPath: String? = _pictureUploads.value?.uploadPath,
|
||||||
wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly,
|
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,
|
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
|
||||||
sourcePath: String? = _pictureUploads.value?.sourcePath,
|
sourcePath: String? = _pictureUploads.value?.sourcePath,
|
||||||
|
sourcePathSyncTimestamps: String? = _pictureUploads.value?.sourcePathSyncTimestamps,
|
||||||
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
|
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
|
||||||
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
|
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
|
||||||
spaceId: String? = _pictureUploads.value?.spaceId,
|
spaceId: String? = _pictureUploads.value?.spaceId,
|
||||||
@@ -220,9 +277,14 @@ class SettingsPictureUploadsViewModel(
|
|||||||
behavior = behavior ?: UploadBehavior.COPY,
|
behavior = behavior ?: UploadBehavior.COPY,
|
||||||
sourcePath = sourcePath.orEmpty(),
|
sourcePath = sourcePath.orEmpty(),
|
||||||
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
||||||
wifiOnly = wifiOnly ?: false,
|
wifiOnly = wifiOnly != false,
|
||||||
chargingOnly = chargingOnly ?: false,
|
allowRoaming = allowRoaming == true,
|
||||||
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
|
mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
|
||||||
|
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
|
||||||
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
|
||||||
|
chargingOnly = chargingOnly == true,
|
||||||
|
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
|
||||||
name = _pictureUploads.value?.name ?: pictureUploadsName,
|
name = _pictureUploads.value?.name ?: pictureUploadsName,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
).also {
|
).also {
|
||||||
@@ -238,7 +300,7 @@ class SettingsPictureUploadsViewModel(
|
|||||||
|
|
||||||
fun getUploadPathString(): String {
|
fun getUploadPathString(): String {
|
||||||
|
|
||||||
val spaceName = handleSpaceName(pictureUploadsSpace?.name)
|
val spaceName = handleSpaceName(pictureUploadsSpace?.name).orEmpty()
|
||||||
val uploadPath = pictureUploads.value?.uploadPath
|
val uploadPath = pictureUploads.value?.uploadPath
|
||||||
val spaceId = pictureUploads.value?.spaceId
|
val spaceId = pictureUploads.value?.spaceId
|
||||||
|
|
||||||
|
|||||||
+111
-23
@@ -31,6 +31,7 @@ import android.provider.DocumentsContract
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -42,19 +43,21 @@ import androidx.preference.SwitchPreferenceCompat
|
|||||||
import eu.qsfera.android.R
|
import eu.qsfera.android.R
|
||||||
import eu.qsfera.android.db.PreferenceManager
|
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_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_BEHAVIOUR
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY
|
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_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_PATH
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_SOURCE
|
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.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
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.showAlertDialog
|
||||||
import eu.qsfera.android.extensions.showMessageInSnackbar
|
import eu.qsfera.android.extensions.showMessageInSnackbar
|
||||||
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
|
import eu.qsfera.android.presentation.accounts.ManageAccountsViewModel
|
||||||
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
||||||
import eu.qsfera.android.utils.DisplayUtils
|
import eu.qsfera.android.utils.DisplayUtils
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -65,9 +68,12 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
private val videosViewModel by viewModel<SettingsVideoUploadsViewModel>()
|
private val videosViewModel by viewModel<SettingsVideoUploadsViewModel>()
|
||||||
private val manageAccountsViewModel by viewModel<ManageAccountsViewModel>()
|
private val manageAccountsViewModel by viewModel<ManageAccountsViewModel>()
|
||||||
|
|
||||||
|
private var prefVideoUploadsStatus: Preference? = null
|
||||||
private var prefEnableVideoUploads: SwitchPreferenceCompat? = null
|
private var prefEnableVideoUploads: SwitchPreferenceCompat? = null
|
||||||
private var prefVideoUploadsPath: Preference? = null
|
private var prefVideoUploadsPath: Preference? = null
|
||||||
private var prefVideoUploadsOnWifi: CheckBoxPreference? = null
|
private var prefVideoUploadsOnWifi: CheckBoxPreference? = null
|
||||||
|
private var prefVideoUploadsAllowRoaming: CheckBoxPreference? = null
|
||||||
|
private var prefVideoUploadsMobileDataLimit: ListPreference? = null
|
||||||
private var prefVideoUploadsOnCharging: CheckBoxPreference? = null
|
private var prefVideoUploadsOnCharging: CheckBoxPreference? = null
|
||||||
private var prefVideoUploadsSourcePath: Preference? = null
|
private var prefVideoUploadsSourcePath: Preference? = null
|
||||||
private var prefVideoUploadsClearSourcePaths: Preference? = null
|
private var prefVideoUploadsClearSourcePaths: Preference? = null
|
||||||
@@ -97,9 +103,12 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.settings_video_uploads, rootKey)
|
setPreferencesFromResource(R.xml.settings_video_uploads, rootKey)
|
||||||
|
|
||||||
|
prefVideoUploadsStatus = findPreference(PREF_VIDEO_UPLOADS_STATUS)
|
||||||
prefEnableVideoUploads = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ENABLED)
|
prefEnableVideoUploads = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ENABLED)
|
||||||
prefVideoUploadsPath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH)
|
prefVideoUploadsPath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH)
|
||||||
prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY)
|
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)
|
prefVideoUploadsOnCharging = findPreference(PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY)
|
||||||
prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE)
|
prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE)
|
||||||
prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS)
|
prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS)
|
||||||
@@ -112,7 +121,9 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME)
|
prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME)
|
||||||
|
|
||||||
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
|
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()
|
initPreferenceListeners()
|
||||||
}
|
}
|
||||||
@@ -126,7 +137,13 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
private fun initLiveDataObservers() {
|
private fun initLiveDataObservers() {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
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 }
|
val availableAccounts = listUserQuotas.filter { it.available != -4L }
|
||||||
prefVideoUploadsAccount?.apply {
|
prefVideoUploadsAccount?.apply {
|
||||||
entries = availableAccounts.map { it.accountName }.toTypedArray()
|
entries = availableAccounts.map { it.accountName }.toTypedArray()
|
||||||
@@ -138,7 +155,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
|
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
|
||||||
} else {
|
} else {
|
||||||
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
|
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
|
||||||
currentAccount?.let {
|
if (currentAccount != null) {
|
||||||
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
|
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
|
||||||
availableAccounts.first().accountName
|
availableAccounts.first().accountName
|
||||||
} else {
|
} else {
|
||||||
@@ -146,18 +163,39 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
videosViewModel.videoUploads.collect { videoUploadsConfiguration ->
|
|
||||||
enableVideoUploads(videoUploadsConfiguration != null, false)
|
enableVideoUploads(videoUploadsConfiguration != null, false)
|
||||||
videoUploadsConfiguration?.let {
|
videoUploadsConfiguration?.let {
|
||||||
prefVideoUploadsAccount?.value = it.accountName
|
prefVideoUploadsAccount?.value = it.accountName
|
||||||
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
|
prefVideoUploadsPath?.summary = videosViewModel.getUploadPathString()
|
||||||
val sourcePaths = videosViewModel.getVideoUploadsSourcePaths()
|
val sourcePaths = it.sourcePaths
|
||||||
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(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()
|
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||||
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
|
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
|
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
|
||||||
prefVideoUploadsBehaviour?.value = it.behavior.name
|
prefVideoUploadsBehaviour?.value = it.behavior.name
|
||||||
prefVideoUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
|
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
|
||||||
spaceId = it.spaceId
|
spaceId = it.spaceId
|
||||||
} ?: resetFields()
|
} ?: resetFields()
|
||||||
}
|
}
|
||||||
@@ -165,7 +203,6 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun initPreferenceListeners() {
|
private fun initPreferenceListeners() {
|
||||||
prefEnableVideoUploads?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
|
prefEnableVideoUploads?.setOnPreferenceChangeListener { _: Preference?, newValue: Any ->
|
||||||
@@ -239,26 +276,40 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
|
prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as Boolean
|
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
|
||||||
videosViewModel.useWifiOnly(newValue)
|
videosViewModel.useWifiOnly(value)
|
||||||
newValue
|
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 ->
|
prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as Boolean
|
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
|
||||||
videosViewModel.useChargingOnly(newValue)
|
videosViewModel.useChargingOnly(value)
|
||||||
newValue
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
|
prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as String
|
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
|
||||||
videosViewModel.handleSelectAccount(newValue)
|
videosViewModel.handleSelectAccount(value)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
|
prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
newValue as String
|
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
|
||||||
videosViewModel.handleSelectBehaviour(newValue)
|
videosViewModel.handleSelectBehaviour(value)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,6 +326,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
prefVideoUploadsPath?.isEnabled = value
|
prefVideoUploadsPath?.isEnabled = value
|
||||||
prefVideoUploadsOnWifi?.isEnabled = value
|
prefVideoUploadsOnWifi?.isEnabled = value
|
||||||
|
prefVideoUploadsAllowRoaming?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
|
||||||
|
prefVideoUploadsMobileDataLimit?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
|
||||||
prefVideoUploadsOnCharging?.isEnabled = value
|
prefVideoUploadsOnCharging?.isEnabled = value
|
||||||
prefVideoUploadsSourcePath?.isEnabled = value
|
prefVideoUploadsSourcePath?.isEnabled = value
|
||||||
prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty()
|
prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty()
|
||||||
@@ -284,26 +337,61 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resetFields() {
|
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
|
prefVideoUploadsAccount?.value = null
|
||||||
prefVideoUploadsPath?.summary = null
|
prefVideoUploadsPath?.summary = null
|
||||||
prefVideoUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
|
prefVideoUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||||
prefVideoUploadsClearSourcePaths?.isEnabled = false
|
prefVideoUploadsClearSourcePaths?.isEnabled = false
|
||||||
prefVideoUploadsOnWifi?.isChecked = false
|
prefVideoUploadsOnWifi?.isChecked = false
|
||||||
|
prefVideoUploadsAllowRoaming?.isChecked = false
|
||||||
|
prefVideoUploadsMobileDataLimit?.value = "0"
|
||||||
prefVideoUploadsOnCharging?.isChecked = false
|
prefVideoUploadsOnCharging?.isChecked = false
|
||||||
prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name
|
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 =
|
private fun getSourcePathsSummary(sourcePathLabels: Map<String, String>): String =
|
||||||
if (sourcePaths.isEmpty()) {
|
if (sourcePathLabels.isEmpty()) {
|
||||||
getString(R.string.prefs_camera_upload_source_paths_empty)
|
getString(R.string.prefs_camera_upload_source_paths_empty)
|
||||||
} else {
|
} else {
|
||||||
sourcePaths.joinToString(separator = "\n") { sourcePath ->
|
sourcePathLabels.values.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSourcePathLabels(sourcePaths: List<String>): Map<String, String> =
|
||||||
|
sourcePaths.associateWith { sourcePath ->
|
||||||
DisplayUtils.getPathWithoutLastSlash(sourcePath.toUri().path)
|
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 {
|
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"
|
private const val PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS = "video_uploads_clear_source_paths"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-5
@@ -30,6 +30,8 @@ import eu.qsfera.android.R
|
|||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
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
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
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.FolderBackUpConfiguration.Companion.videoUploadsName
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
|
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.model.OCSpace
|
||||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
||||||
import eu.qsfera.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase
|
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.AccountProvider
|
||||||
import eu.qsfera.android.providers.ContextProvider
|
import eu.qsfera.android.providers.ContextProvider
|
||||||
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
|
||||||
import eu.qsfera.android.providers.WorkManagerProvider
|
import eu.qsfera.android.providers.WorkManagerProvider
|
||||||
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
import eu.qsfera.android.ui.activity.FolderPickerActivity
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -57,6 +64,7 @@ class SettingsVideoUploadsViewModel(
|
|||||||
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
|
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
|
||||||
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
||||||
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
|
private val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase,
|
||||||
|
getAllTransfersAsStreamUseCase: GetAllTransfersAsStreamUseCase,
|
||||||
private val workManagerProvider: WorkManagerProvider,
|
private val workManagerProvider: WorkManagerProvider,
|
||||||
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
private val coroutinesDispatcherProvider: CoroutinesDispatcherProvider,
|
||||||
private val contextProvider: ContextProvider,
|
private val contextProvider: ContextProvider,
|
||||||
@@ -67,6 +75,20 @@ class SettingsVideoUploadsViewModel(
|
|||||||
|
|
||||||
private var videoUploadsSpace: OCSpace? = null
|
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 {
|
init {
|
||||||
initVideoUploads()
|
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) {
|
fun useChargingOnly(chargingOnly: Boolean) {
|
||||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
saveVideoUploadsConfigurationUseCase(
|
saveVideoUploadsConfigurationUseCase(
|
||||||
@@ -175,13 +217,22 @@ class SettingsVideoUploadsViewModel(
|
|||||||
val previousSourcePaths = getVideoUploadsSourcePaths()
|
val previousSourcePaths = getVideoUploadsSourcePaths()
|
||||||
val newSourcePath = contentUriForTree.toString()
|
val newSourcePath = contentUriForTree.toString()
|
||||||
val updatedSourcePaths = (previousSourcePaths + newSourcePath).distinct()
|
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) {
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
saveVideoUploadsConfigurationUseCase(
|
saveVideoUploadsConfigurationUseCase(
|
||||||
SaveVideoUploadsConfigurationUseCase.Params(
|
SaveVideoUploadsConfigurationUseCase.Params(
|
||||||
composeVideoUploadsConfiguration(
|
composeVideoUploadsConfiguration(
|
||||||
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
sourcePath = encodeSourcePaths(updatedSourcePaths),
|
||||||
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
|
sourcePathSyncTimestamps = encodeSourcePathSyncTimestamps(updatedSourceSyncTimestamps)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -194,6 +245,7 @@ class SettingsVideoUploadsViewModel(
|
|||||||
SaveVideoUploadsConfigurationUseCase.Params(
|
SaveVideoUploadsConfigurationUseCase.Params(
|
||||||
composeVideoUploadsConfiguration(
|
composeVideoUploadsConfiguration(
|
||||||
sourcePath = "",
|
sourcePath = "",
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -210,8 +262,13 @@ class SettingsVideoUploadsViewModel(
|
|||||||
accountName: String? = _videoUploads.value?.accountName,
|
accountName: String? = _videoUploads.value?.accountName,
|
||||||
uploadPath: String? = _videoUploads.value?.uploadPath,
|
uploadPath: String? = _videoUploads.value?.uploadPath,
|
||||||
wifiOnly: Boolean? = _videoUploads.value?.wifiOnly,
|
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,
|
chargingOnly: Boolean? = _videoUploads.value?.chargingOnly,
|
||||||
sourcePath: String? = _videoUploads.value?.sourcePath,
|
sourcePath: String? = _videoUploads.value?.sourcePath,
|
||||||
|
sourcePathSyncTimestamps: String? = _videoUploads.value?.sourcePathSyncTimestamps,
|
||||||
behavior: UploadBehavior? = _videoUploads.value?.behavior,
|
behavior: UploadBehavior? = _videoUploads.value?.behavior,
|
||||||
timestamp: Long? = _videoUploads.value?.lastSyncTimestamp,
|
timestamp: Long? = _videoUploads.value?.lastSyncTimestamp,
|
||||||
spaceId: String? = _videoUploads.value?.spaceId,
|
spaceId: String? = _videoUploads.value?.spaceId,
|
||||||
@@ -221,9 +278,14 @@ class SettingsVideoUploadsViewModel(
|
|||||||
behavior = behavior ?: UploadBehavior.COPY,
|
behavior = behavior ?: UploadBehavior.COPY,
|
||||||
sourcePath = sourcePath.orEmpty(),
|
sourcePath = sourcePath.orEmpty(),
|
||||||
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
||||||
wifiOnly = wifiOnly ?: false,
|
wifiOnly = wifiOnly != false,
|
||||||
chargingOnly = chargingOnly ?: false,
|
allowRoaming = allowRoaming == true,
|
||||||
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
|
mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
|
||||||
|
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
|
||||||
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
|
||||||
|
chargingOnly = chargingOnly == true,
|
||||||
|
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps.orEmpty(),
|
||||||
name = _videoUploads.value?.name ?: videoUploadsName,
|
name = _videoUploads.value?.name ?: videoUploadsName,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
).also {
|
).also {
|
||||||
@@ -239,7 +301,7 @@ class SettingsVideoUploadsViewModel(
|
|||||||
|
|
||||||
fun getUploadPathString(): String {
|
fun getUploadPathString(): String {
|
||||||
|
|
||||||
val spaceName = handleSpaceName(videoUploadsSpace?.name)
|
val spaceName = handleSpaceName(videoUploadsSpace?.name).orEmpty()
|
||||||
val uploadPath = videoUploads.value?.uploadPath
|
val uploadPath = videoUploads.value?.uploadPath
|
||||||
val spaceId = videoUploads.value?.spaceId
|
val spaceId = videoUploads.value?.spaceId
|
||||||
|
|
||||||
|
|||||||
+85
-17
@@ -44,7 +44,7 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { fetchLatestRelease(context) }
|
withContext(Dispatchers.IO) { fetchLatestRelease(context) }
|
||||||
}.onSuccess { release ->
|
}.onSuccess { release ->
|
||||||
if (release.androidVersionCode <= BuildConfig.VERSION_CODE) {
|
if (release == null || compareVersions(release.version, BuildConfig.VERSION_NAME) <= 0) {
|
||||||
showNoUpdatesDialog(context)
|
showNoUpdatesDialog(context)
|
||||||
} else {
|
} else {
|
||||||
showUpdateDialog(context, release)
|
showUpdateDialog(context, release)
|
||||||
@@ -62,9 +62,7 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
context.getString(
|
context.getString(
|
||||||
R.string.update_available_message,
|
R.string.update_available_message,
|
||||||
release.version,
|
release.version,
|
||||||
release.androidVersionCode,
|
BuildConfig.VERSION_NAME
|
||||||
BuildConfig.VERSION_NAME,
|
|
||||||
BuildConfig.VERSION_CODE
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
@@ -95,8 +93,13 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
fragment.lifecycleScope.launch {
|
fragment.lifecycleScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
downloadApk(context, release).also { apk ->
|
val apk = downloadApk(context, release)
|
||||||
|
try {
|
||||||
validateDownloadedApk(context, apk, release)
|
validateDownloadedApk(context, apk, release)
|
||||||
|
apk
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
apk.delete()
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onSuccess { apk ->
|
}.onSuccess { apk ->
|
||||||
@@ -123,6 +126,15 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showErrorDialog(context: Context, error: Throwable) {
|
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)
|
AlertDialog.Builder(context)
|
||||||
.setTitle(R.string.common_error)
|
.setTitle(R.string.common_error)
|
||||||
.setMessage(context.getString(R.string.update_error, error.message ?: error.javaClass.simpleName))
|
.setMessage(context.getString(R.string.update_error, error.message ?: error.javaClass.simpleName))
|
||||||
@@ -130,25 +142,45 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchLatestRelease(context: Context): ArgusRelease {
|
private fun fetchLatestRelease(context: Context): ArgusRelease? {
|
||||||
val json = requestText(ARGUS_MANIFEST_URL)
|
val json = requestText(ARGUS_MANIFEST_URL) ?: return null
|
||||||
val release = JSONObject(json).optJSONObject("release")
|
val release = JSONObject(json).optJSONObject("release")
|
||||||
?: error(context.getString(R.string.update_release_not_found))
|
?: 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(
|
return ArgusRelease(
|
||||||
version = release.getString("version"),
|
version = version,
|
||||||
androidVersionCode = release.getInt("androidVersionCode"),
|
androidVersionCode = release.optInt("androidVersionCode").takeIf { it > 0 },
|
||||||
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
|
downloadUrl = downloadPath.toAbsoluteArgusUrl(),
|
||||||
sha256 = release.optString("sha256")
|
packageSizeBytes = packageSizeBytes,
|
||||||
|
sha256 = sha256.lowercase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestText(url: String): String {
|
private fun requestText(url: String): String? {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
return try {
|
return try {
|
||||||
connection.connectTimeout = NETWORK_TIMEOUT_MS
|
connection.connectTimeout = NETWORK_TIMEOUT_MS
|
||||||
connection.readTimeout = NETWORK_TIMEOUT_MS
|
connection.readTimeout = NETWORK_TIMEOUT_MS
|
||||||
connection.requestMethod = "GET"
|
connection.requestMethod = "GET"
|
||||||
|
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
check(connection.responseCode in 200..299) {
|
check(connection.responseCode in 200..299) {
|
||||||
"HTTP ${connection.responseCode}"
|
"HTTP ${connection.responseCode}"
|
||||||
}
|
}
|
||||||
@@ -161,6 +193,9 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
private fun downloadApk(context: Context, release: ArgusRelease): File {
|
private fun downloadApk(context: Context, release: ArgusRelease): File {
|
||||||
val updatesDir = File(context.filesDir, "updates").apply { mkdirs() }
|
val updatesDir = File(context.filesDir, "updates").apply { mkdirs() }
|
||||||
val apkFile = File(updatesDir, UPDATE_APK_NAME)
|
val apkFile = File(updatesDir, UPDATE_APK_NAME)
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
apkFile.delete()
|
||||||
|
}
|
||||||
val connection = URL(release.downloadUrl).openConnection() as HttpURLConnection
|
val connection = URL(release.downloadUrl).openConnection() as HttpURLConnection
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -179,7 +214,13 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
connection.disconnect()
|
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))
|
error(context.getString(R.string.update_digest_error))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,12 +252,15 @@ class AppUpdateManager(private val fragment: Fragment) {
|
|||||||
if (downloadedVersionCode <= BuildConfig.VERSION_CODE) {
|
if (downloadedVersionCode <= BuildConfig.VERSION_CODE) {
|
||||||
error(context.getString(R.string.update_version_error))
|
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))
|
error(context.getString(R.string.update_metadata_error))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasTrustedSignature(context, downloadedPackageInfo)) {
|
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) }
|
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(
|
private data class ArgusRelease(
|
||||||
val version: String,
|
val version: String,
|
||||||
val androidVersionCode: Int,
|
val androidVersionCode: Int?,
|
||||||
val downloadUrl: String,
|
val downloadUrl: String,
|
||||||
|
val packageSizeBytes: Long,
|
||||||
val sha256: String
|
val sha256: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private class SignatureMismatchException(message: String) : IllegalStateException(message)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz"
|
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 =
|
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 UPDATE_APK_NAME = "qsfera-update.apk"
|
||||||
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
||||||
private const val NETWORK_TIMEOUT_MS = 15_000
|
private const val NETWORK_TIMEOUT_MS = 15_000
|
||||||
|
|||||||
+36
-7
@@ -238,16 +238,16 @@ class PublicShareDialogFragment : DialogFragment() {
|
|||||||
if (updating()) {
|
if (updating()) {
|
||||||
binding.publicShareDialogTitle.setText(R.string.share_via_link_edit_title)
|
binding.publicShareDialogTitle.setText(R.string.share_via_link_edit_title)
|
||||||
binding.shareViaLinkNameValue.setText(publicShare?.name)
|
binding.shareViaLinkNameValue.setText(publicShare?.name)
|
||||||
|
val publicShareStatus = publicShare?.let { PublicShareStatusFormatter.statusFor(it) }
|
||||||
|
|
||||||
when (publicShare?.permissions) {
|
when (publicShareStatus?.accessMode) {
|
||||||
RemoteShare.CREATE_PERMISSION_FLAG
|
PublicShareAccessMode.READ_AND_WRITE ->
|
||||||
or RemoteShare.DELETE_PERMISSION_FLAG
|
|
||||||
or RemoteShare.UPDATE_PERMISSION_FLAG
|
|
||||||
or RemoteShare.READ_PERMISSION_FLAG ->
|
|
||||||
binding.shareViaLinkEditPermissionReadAndWrite.isChecked = true
|
binding.shareViaLinkEditPermissionReadAndWrite.isChecked = true
|
||||||
|
|
||||||
RemoteShare.CREATE_PERMISSION_FLAG -> binding.shareViaLinkEditPermissionUploadFiles.isChecked = true
|
PublicShareAccessMode.UPLOAD_ONLY -> binding.shareViaLinkEditPermissionUploadFiles.isChecked = true
|
||||||
else -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true
|
PublicShareAccessMode.READ_ONLY -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true
|
||||||
|
PublicShareAccessMode.CUSTOM -> binding.shareViaLinkEditPermissionGroup.clearCheck()
|
||||||
|
null -> binding.shareViaLinkEditPermissionReadOnly.isChecked = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (publicShare?.isPasswordProtected == true) {
|
if (publicShare?.isPasswordProtected == true) {
|
||||||
@@ -265,12 +265,19 @@ class PublicShareDialogFragment : DialogFragment() {
|
|||||||
binding.shareViaLinkExpirationValue.text = formattedDate
|
binding.shareViaLinkExpirationValue.text = formattedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (publicShareStatus?.isEditableInOcsDialog == false) {
|
||||||
|
applyServerManagedPublicLinkState()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
binding.shareViaLinkNameValue.setText(arguments?.getString(ARG_DEFAULT_LINK_NAME, ""))
|
binding.shareViaLinkNameValue.setText(arguments?.getString(ARG_DEFAULT_LINK_NAME, ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSaveShareSetting() {
|
private fun onSaveShareSetting() {
|
||||||
|
if (isServerManagedPublicLink()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get data filled by user
|
// Get data filled by user
|
||||||
val publicLinkName = binding.shareViaLinkNameValue.text.toString()
|
val publicLinkName = binding.shareViaLinkNameValue.text.toString()
|
||||||
var publicLinkPassword: String? = binding.shareViaLinkPasswordValue.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
|
QSferaVersion(it, it) // FIXME: check productversion as second parameter, not versionstring
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isServerManagedPublicLink()) {
|
||||||
|
applyServerManagedPublicLinkState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Show keyboard to fill the public share name
|
// Show keyboard to fill the public share name
|
||||||
dialog?.window?.setSoftInputMode(
|
dialog?.window?.setSoftInputMode(
|
||||||
WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
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 &&
|
private fun isReadOnlyPermission() = binding.shareViaLinkEditPermissionReadOnly.isChecked &&
|
||||||
capabilities?.filesSharingPublicPasswordEnforcedReadOnly == CapabilityBooleanType.TRUE
|
capabilities?.filesSharingPublicPasswordEnforcedReadOnly == CapabilityBooleanType.TRUE
|
||||||
|
|
||||||
|
|||||||
+54
@@ -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,
|
||||||
|
}
|
||||||
+34
-4
@@ -30,7 +30,10 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import eu.qsfera.android.databinding.SharePublicLinkItemBinding
|
import eu.qsfera.android.databinding.SharePublicLinkItemBinding
|
||||||
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
import eu.qsfera.android.domain.sharing.shares.model.OCShare
|
||||||
|
import eu.qsfera.android.R
|
||||||
import eu.qsfera.android.utils.PreferenceUtils
|
import eu.qsfera.android.utils.PreferenceUtils
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter to show a list of public links
|
* Adapter to show a list of public links
|
||||||
@@ -42,8 +45,6 @@ class SharePublicLinkListAdapter(
|
|||||||
private val listener: SharePublicLinkAdapterListener
|
private val listener: SharePublicLinkAdapterListener
|
||||||
) : ArrayAdapter<OCShare>(mContext, resource) {
|
) : ArrayAdapter<OCShare>(mContext, resource) {
|
||||||
|
|
||||||
private lateinit var binding: SharePublicLinkItemBinding
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
publicLinks = publicLinks.sortedBy { it.name }
|
publicLinks = publicLinks.sortedBy { it.name }
|
||||||
}
|
}
|
||||||
@@ -55,8 +56,8 @@ class SharePublicLinkListAdapter(
|
|||||||
override fun getItemId(position: Int): Long = 0
|
override fun getItemId(position: Int): Long = 0
|
||||||
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
val inflator = mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
val inflator = LayoutInflater.from(mContext)
|
||||||
binding = SharePublicLinkItemBinding.inflate(inflator).apply {
|
val binding = SharePublicLinkItemBinding.inflate(inflator, parent, false).apply {
|
||||||
root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext)
|
root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(mContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ class SharePublicLinkListAdapter(
|
|||||||
|
|
||||||
// If there's no name, set the token as name
|
// If there's no name, set the token as name
|
||||||
binding.publicLinkName.text = if (share.name.isNullOrEmpty()) share.token else share.name
|
binding.publicLinkName.text = if (share.name.isNullOrEmpty()) share.token else share.name
|
||||||
|
binding.publicLinkDetails.text = formatPublicLinkDetails(share)
|
||||||
|
|
||||||
// bind listener to get link
|
// bind listener to get link
|
||||||
binding.getPublicLinkButton.setOnClickListener { listener.copyOrSendPublicLink(publicLinks[position]) }
|
binding.getPublicLinkButton.setOnClickListener { listener.copyOrSendPublicLink(publicLinks[position]) }
|
||||||
@@ -79,6 +81,34 @@ class SharePublicLinkListAdapter(
|
|||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatPublicLinkDetails(share: OCShare): String {
|
||||||
|
val status = PublicShareStatusFormatter.statusFor(share)
|
||||||
|
val expirationText = status.expirationDateInMillis?.let {
|
||||||
|
mContext.getString(
|
||||||
|
R.string.share_public_link_status_expires,
|
||||||
|
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it))
|
||||||
|
)
|
||||||
|
} ?: mContext.getString(R.string.share_public_link_status_no_expiration)
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
mContext.getString(status.accessMode.stringResId),
|
||||||
|
if (status.isPasswordProtected) {
|
||||||
|
mContext.getString(R.string.share_public_link_status_password_protected)
|
||||||
|
} else {
|
||||||
|
mContext.getString(R.string.share_public_link_status_no_password)
|
||||||
|
},
|
||||||
|
expirationText
|
||||||
|
).joinToString(separator = " • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PublicShareAccessMode.stringResId: Int
|
||||||
|
get() = when (this) {
|
||||||
|
PublicShareAccessMode.READ_ONLY -> R.string.share_public_link_status_read_only
|
||||||
|
PublicShareAccessMode.READ_AND_WRITE -> R.string.share_public_link_status_read_and_write
|
||||||
|
PublicShareAccessMode.UPLOAD_ONLY -> R.string.share_public_link_status_upload_only
|
||||||
|
PublicShareAccessMode.CUSTOM -> R.string.share_public_link_status_custom
|
||||||
|
}
|
||||||
|
|
||||||
interface SharePublicLinkAdapterListener {
|
interface SharePublicLinkAdapterListener {
|
||||||
fun copyOrSendPublicLink(share: OCShare)
|
fun copyOrSendPublicLink(share: OCShare)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import eu.qsfera.android.workers.OldLogsCollectorWorker
|
|||||||
import eu.qsfera.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker
|
import eu.qsfera.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker
|
||||||
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
|
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
|
||||||
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
|
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ class WorkManagerProvider(
|
|||||||
* concurrent scans or redundant enqueues on rapid foreground/background switches.
|
* concurrent scans or redundant enqueues on rapid foreground/background switches.
|
||||||
*/
|
*/
|
||||||
fun enqueueImmediateAutomaticUploadsWorker() {
|
fun enqueueImmediateAutomaticUploadsWorker() {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val wm = WorkManager.getInstance(context)
|
val wm = WorkManager.getInstance(context)
|
||||||
|
|
||||||
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
|
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
|
||||||
@@ -99,12 +103,12 @@ class WorkManagerProvider(
|
|||||||
|
|
||||||
if (periodicRunning || immediateRunning) {
|
if (periodicRunning || immediateRunning) {
|
||||||
Timber.d("Automatic uploads worker already running, skipping immediate run")
|
Timber.d("Automatic uploads worker already running, skipping immediate run")
|
||||||
return
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
|
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
|
||||||
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
|
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
|
||||||
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
wm.enqueueUniqueWork(
|
wm.enqueueUniqueWork(
|
||||||
@@ -113,6 +117,7 @@ class WorkManagerProvider(
|
|||||||
immediateWorker
|
immediateWorker
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun enqueueOldLogsCollectorWorker() {
|
fun enqueueOldLogsCollectorWorker() {
|
||||||
val constraintsRequired = Constraints.Builder().setRequiredNetworkType(NetworkType.NOT_REQUIRED).build()
|
val constraintsRequired = Constraints.Builder().setRequiredNetworkType(NetworkType.NOT_REQUIRED).build()
|
||||||
|
|||||||
+11
-1
@@ -58,7 +58,7 @@ class UploadFileFromContentUriUseCase(
|
|||||||
.putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString())
|
.putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val networkRequired = if (params.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
|
val networkRequired = getRequiredNetworkType(params.wifiOnly, params.allowRoaming)
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(networkRequired)
|
.setRequiredNetworkType(networkRequired)
|
||||||
.setRequiresCharging(params.chargingOnly)
|
.setRequiresCharging(params.chargingOnly)
|
||||||
@@ -109,6 +109,16 @@ class UploadFileFromContentUriUseCase(
|
|||||||
val uploadPath: String,
|
val uploadPath: String,
|
||||||
val uploadIdInStorageManager: Long,
|
val uploadIdInStorageManager: Long,
|
||||||
val wifiOnly: Boolean,
|
val wifiOnly: Boolean,
|
||||||
|
val allowRoaming: Boolean = true,
|
||||||
val chargingOnly: Boolean,
|
val chargingOnly: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getRequiredNetworkType(wifiOnly: Boolean, allowRoaming: Boolean): NetworkType =
|
||||||
|
when {
|
||||||
|
wifiOnly -> NetworkType.UNMETERED
|
||||||
|
allowRoaming -> NetworkType.CONNECTED
|
||||||
|
else -> NetworkType.NOT_ROAMING
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-15
@@ -22,6 +22,7 @@
|
|||||||
package eu.qsfera.android.workers
|
package eu.qsfera.android.workers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
@@ -51,7 +52,9 @@ import org.koin.core.component.inject
|
|||||||
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import java.util.TimeZone
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class AutomaticUploadsWorker(
|
class AutomaticUploadsWorker(
|
||||||
@@ -128,8 +131,13 @@ class AutomaticUploadsWorker(
|
|||||||
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
|
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
|
||||||
sourcePaths.forEach { sourcePath ->
|
sourcePaths.forEach { sourcePath ->
|
||||||
val sourceUri: Uri = sourcePath.toUri()
|
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")
|
?: 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)
|
WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration?) {
|
private fun isActiveNetworkMetered(): Boolean {
|
||||||
if (folderBackUpConfiguration == null) return
|
val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
return connectivityManager?.isActiveNetworkMetered != false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration) {
|
||||||
val syncType = when {
|
val syncType = when {
|
||||||
folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS
|
folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS
|
||||||
folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS
|
folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS
|
||||||
@@ -148,29 +159,50 @@ class AutomaticUploadsWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val currentTimestamp = System.currentTimeMillis()
|
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(
|
getFilesReadyToUpload(
|
||||||
syncType = syncType,
|
syncType = syncType,
|
||||||
sourcePath = sourcePath,
|
sourcePath = sourcePath,
|
||||||
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
|
lastSyncTimestamp = lastSyncTimestamp,
|
||||||
currentTimestamp = currentTimestamp,
|
currentTimestamp = currentTimestamp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
showNotification(syncType, localPicturesDocumentFiles.size)
|
var queuedUploadsCount = 0
|
||||||
|
|
||||||
for (documentFile in localPicturesDocumentFiles) {
|
for (documentFile in localPicturesDocumentFiles) {
|
||||||
// Dedup: if this content URI already has a queued, in-progress, or succeeded transfer,
|
// 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
|
// 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
|
// 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.
|
// when two workers race on the same cache path.
|
||||||
val contentUri = documentFile.uri.toString()
|
val contentUri = documentFile.uri.toString()
|
||||||
if (transferRepository.existsNonFailedTransferForUri(contentUri)) {
|
if (transferRepository.existsNonFailedTransferForUri(contentUri)) {
|
||||||
Timber.d("Skipping already-tracked file: %s", documentFile.name)
|
Timber.d("Skipping already-tracked file: %s", documentFile.name)
|
||||||
continue
|
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(
|
val uploadId = storeInUploadsDatabase(
|
||||||
documentFile = documentFile,
|
documentFile = documentFile,
|
||||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
||||||
@@ -190,13 +222,42 @@ class AutomaticUploadsWorker(
|
|||||||
accountName = folderBackUpConfiguration.accountName,
|
accountName = folderBackUpConfiguration.accountName,
|
||||||
uploadId = uploadId,
|
uploadId = uploadId,
|
||||||
wifiOnly = folderBackUpConfiguration.wifiOnly,
|
wifiOnly = folderBackUpConfiguration.wifiOnly,
|
||||||
|
allowRoaming = folderBackUpConfiguration.allowRoaming,
|
||||||
chargingOnly = folderBackUpConfiguration.chargingOnly
|
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
|
// Save safeTimestamp (not currentTimestamp) so that files skipped by the
|
||||||
// write-safety buffer are re-evaluated on the next run instead of being lost.
|
// 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
|
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(
|
private fun showNotification(
|
||||||
@@ -245,23 +306,31 @@ class AutomaticUploadsWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateTimestamp(
|
private fun updateConfigurationAfterScan(
|
||||||
folderBackUpConfiguration: FolderBackUpConfiguration,
|
folderBackUpConfiguration: FolderBackUpConfiguration,
|
||||||
syncType: SyncType,
|
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) {
|
when (syncType) {
|
||||||
SyncType.PICTURE_UPLOADS -> {
|
SyncType.PICTURE_UPLOADS -> {
|
||||||
val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject()
|
val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject()
|
||||||
savePictureUploadsConfigurationUseCase(
|
savePictureUploadsConfigurationUseCase(
|
||||||
SavePictureUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp))
|
SavePictureUploadsConfigurationUseCase.Params(updatedConfiguration)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SyncType.VIDEO_UPLOADS -> {
|
SyncType.VIDEO_UPLOADS -> {
|
||||||
val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject()
|
val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject()
|
||||||
saveVideoUploadsConfigurationUseCase(
|
saveVideoUploadsConfigurationUseCase(
|
||||||
SaveVideoUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp))
|
SaveVideoUploadsConfigurationUseCase.Params(updatedConfiguration)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +344,7 @@ class AutomaticUploadsWorker(
|
|||||||
): List<DocumentFile> {
|
): List<DocumentFile> {
|
||||||
val sourceUri: Uri = sourcePath.toUri()
|
val sourceUri: Uri = sourcePath.toUri()
|
||||||
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
|
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
|
// 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
|
// 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("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
|
||||||
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
|
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")
|
Timber.i("${filteredList.size} files are ${syncType.name} and were taken after last sync")
|
||||||
|
|
||||||
return filteredList
|
return filteredList
|
||||||
@@ -304,6 +373,7 @@ class AutomaticUploadsWorker(
|
|||||||
accountName: String,
|
accountName: String,
|
||||||
uploadId: Long,
|
uploadId: Long,
|
||||||
wifiOnly: Boolean,
|
wifiOnly: Boolean,
|
||||||
|
allowRoaming: Boolean,
|
||||||
chargingOnly: Boolean
|
chargingOnly: Boolean
|
||||||
) {
|
) {
|
||||||
val lastModifiedInSeconds = (lastModified / 1000L).toString()
|
val lastModifiedInSeconds = (lastModified / 1000L).toString()
|
||||||
@@ -317,6 +387,7 @@ class AutomaticUploadsWorker(
|
|||||||
uploadPath = uploadPath,
|
uploadPath = uploadPath,
|
||||||
uploadIdInStorageManager = uploadId,
|
uploadIdInStorageManager = uploadId,
|
||||||
wifiOnly = wifiOnly,
|
wifiOnly = wifiOnly,
|
||||||
|
allowRoaming = allowRoaming,
|
||||||
chargingOnly = chargingOnly
|
chargingOnly = chargingOnly
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -347,6 +418,11 @@ class AutomaticUploadsWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
data class MobileDataQuotaState(
|
||||||
|
val periodStartTimestamp: Long,
|
||||||
|
val bytesQueuedInPeriod: Long,
|
||||||
|
)
|
||||||
|
|
||||||
const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER"
|
const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER"
|
||||||
const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER"
|
const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER"
|
||||||
const val MEDIA_STORE_UPLOADS_WORKER = "MEDIA_STORE_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
|
private const val videoUploadsNotificationId = 102
|
||||||
const val WRITE_SAFETY_BUFFER_MS = 10_000L
|
const val WRITE_SAFETY_BUFFER_MS = 10_000L
|
||||||
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_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>
|
</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
|
<RelativeLayout
|
||||||
android:id="@+id/shareViaLinkPasswordSection"
|
android:id="@+id/shareViaLinkPasswordSection"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
-->
|
-->
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -25,8 +25,7 @@
|
|||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:weightSum="1">
|
android:weightSum="1">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/publicLinkName"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center_vertical"
|
android:layout_gravity="center_vertical"
|
||||||
@@ -34,12 +33,29 @@
|
|||||||
android:layout_marginStart="@dimen/standard_half_margin"
|
android:layout_marginStart="@dimen/standard_half_margin"
|
||||||
android:layout_marginTop="@dimen/standard_half_margin"
|
android:layout_marginTop="@dimen/standard_half_margin"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/publicLinkName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:ellipsize="middle"
|
android:ellipsize="middle"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="@string/username"
|
android:text="@string/username"
|
||||||
android:textColor="@color/textColor"
|
android:textColor="@color/textColor"
|
||||||
android:textSize="@dimen/two_line_primary_text_size" />
|
android:textSize="@dimen/two_line_primary_text_size" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/publicLinkDetails"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:textColor="@color/list_item_lastmod_and_filesize_text"
|
||||||
|
android:textSize="@dimen/two_line_secondary_text_size" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/getPublicLinkButton"
|
android:id="@+id/getPublicLinkButton"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
|
|||||||
@@ -60,23 +60,23 @@
|
|||||||
<string name="prefs_touches_with_other_visible_windows_summary">Разрешает взаимодействие, когда есть перекрытие другим видимым или прозрачным окном. Это нужно, если вы используете приложения для фильтрации света от экрана. Перекрывающее приложение сможет получать информацию о взаимодействии.</string>
|
<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_title">Вы уверены, что хотите включить эту функцию?</string>
|
||||||
<string name="confirmation_touches_with_other_windows_message">Используйте эту функцию на свой страх и риск. Вредоносное приложение может попытаться обманом заставить вас выполнить какие-либо действия, используя другие представления.</string>
|
<string name="confirmation_touches_with_other_windows_message">Используйте эту функцию на свой страх и риск. Вредоносное приложение может попытаться обманом заставить вас выполнить какие-либо действия, используя другие представления.</string>
|
||||||
<string name="prefs_subsection_picture_uploads">Автоматическая загрузка изображений</string>
|
<string name="prefs_subsection_picture_uploads">Резервное копирование фото</string>
|
||||||
<string name="prefs_subsection_picture_uploads_summary">Управляйте местоположением и поведением автоматически загружаемых фотографий</string>
|
<string name="prefs_subsection_picture_uploads_summary">Выберите папки телефона и условия загрузки</string>
|
||||||
<string name="prefs_subsection_video_uploads">Автоматическая загрузка видео</string>
|
<string name="prefs_subsection_video_uploads">Резервное копирование видео</string>
|
||||||
<string name="prefs_subsection_video_uploads_summary">Настройка размещения и поведения видеороликов, загружаемых автоматически</string>
|
<string name="prefs_subsection_video_uploads_summary">Выберите папки с видео и условия загрузки</string>
|
||||||
<string name="prefs_camera_picture_upload">Загрузка изображений</string>
|
<string name="prefs_camera_picture_upload">Копировать фото</string>
|
||||||
<string name="prefs_camera_picture_upload_summary">Автоматически загружать изображения, сделанные камерой</string>
|
<string name="prefs_camera_picture_upload_summary">Автоматически копировать фото из выбранных папок телефона</string>
|
||||||
<string name="prefs_camera_picture_upload_path_title">Путь загрузки изображений</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_title">Условия загрузки</string>
|
||||||
<string name="prefs_camera_picture_upload_conditions_summary">Автозагрузка будет запущена только при удовлетворении выбранных ниже условий</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_wifi">Копировать фото только по Wi-Fi</string>
|
||||||
<string name="prefs_camera_picture_upload_on_charging">Загрузка изображений только во время зарядки</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_upload_on_charging_summary">Чтобы включить этот параметр, сначала включите загрузку только по Wi-Fi</string>
|
||||||
<string name="prefs_camera_video_upload">Загрузка видео</string>
|
<string name="prefs_camera_video_upload">Копировать видео</string>
|
||||||
<string name="prefs_camera_video_upload_summary">Автоматически загружать видео, записанные камерой</string>
|
<string name="prefs_camera_video_upload_summary">Автоматически копировать видео из выбранных папок телефона</string>
|
||||||
<string name="prefs_camera_video_upload_path_title">Путь загрузки видео</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_wifi">Копировать видео только по Wi-Fi</string>
|
||||||
<string name="prefs_camera_video_upload_on_charging">Загрузка видео только во время зарядки</string>
|
<string name="prefs_camera_video_upload_on_charging">Копировать видео только во время зарядки</string>
|
||||||
<string name="prefs_automatic_uploads_not_available_users_light">Автоматические загрузки недоступны для пользователей Light</string>
|
<string name="prefs_automatic_uploads_not_available_users_light">Автоматические загрузки недоступны для пользователей Light</string>
|
||||||
<string name="prefs_subsection_advanced">Дополнительно</string>
|
<string name="prefs_subsection_advanced">Дополнительно</string>
|
||||||
<string name="prefs_subsection_notifications">Управление уведомлениями</string>
|
<string name="prefs_subsection_notifications">Управление уведомлениями</string>
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
<string name="update_checking">Проверка обновлений…</string>
|
<string name="update_checking">Проверка обновлений…</string>
|
||||||
<string name="update_downloading">Скачивание обновления…</string>
|
<string name="update_downloading">Скачивание обновления…</string>
|
||||||
<string name="update_available_title">Доступно обновление</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_title">Обновлений нет</string>
|
||||||
<string name="update_not_available_message">Установлена последняя доступная версия.</string>
|
<string name="update_not_available_message">Установлена последняя доступная версия.</string>
|
||||||
<string name="update_install">Установить</string>
|
<string name="update_install">Установить</string>
|
||||||
@@ -123,6 +123,8 @@
|
|||||||
<string name="update_version_error">Скачанное обновление не новее установленного приложения.</string>
|
<string name="update_version_error">Скачанное обновление не новее установленного приложения.</string>
|
||||||
<string name="update_metadata_error">Скачанное обновление не совпадает с метаданными релиза в Аргус.</string>
|
<string name="update_metadata_error">Скачанное обновление не совпадает с метаданными релиза в Аргус.</string>
|
||||||
<string name="update_signature_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_apk_parse_error">Скачанное обновление не удалось прочитать как Android-пакет.</string>
|
||||||
<string name="update_release_not_found">Последний Android-релиз не найден.</string>
|
<string name="update_release_not_found">Последний Android-релиз не найден.</string>
|
||||||
<string name="pattern_enter_pattern">Пожалуйста, введите графический ключ</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">%1$d файлов</string>
|
||||||
<string name="file_list__footer__files_and_folder">%1$d файлов, 1 каталог</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="file_list__footer__files_and_folders">%1$d файлов, %2$d каталогов</string>
|
||||||
<string name="prefs_picture_upload_account">Учётная запись для закачки изображений</string>
|
<string name="prefs_picture_upload_account">Учётная запись для фото</string>
|
||||||
<string name="prefs_video_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">Папки телефона (%1$s)</string>
|
||||||
<string name="prefs_camera_upload_source_path_title_required">обязательно</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_clear_title">Убрать выбранные папки</string>
|
||||||
<string name="prefs_camera_upload_source_paths_empty">Папки не выбраны</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_title">Убрать выбранные папки</string>
|
||||||
<string name="confirmation_clear_camera_upload_sources_message">Автозагрузка перестанет проверять папки телефона, пока вы снова не добавите папку.</string>
|
<string name="confirmation_clear_camera_upload_sources_message">Копирование перестанет проверять папки телефона, пока вы снова не добавите папку.</string>
|
||||||
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл будет</string>
|
<string name="prefs_camera_upload_status_title">Состояние копирования</string>
|
||||||
<string name="prefs_camera_upload_behaviour_title">Исходный файл будет</string>
|
<string name="prefs_camera_upload_status_off">Выключено</string>
|
||||||
<string name="prefs_camera_upload_last_sync_title">Последняя синхронизация</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="notifications_permission_denied">Вы можете обновить свои установки в Настройки</string>
|
||||||
<string name="upload_copy_files">Скопировать файл</string>
|
<string name="upload_copy_files">Скопировать файл</string>
|
||||||
<string name="upload_move_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_only_label">Скачивание / Просмотр</string>
|
||||||
<string name="share_via_link_edit_permission_read_and_write_label">Скачивание / Просмотр / Закачка</string>
|
<string name="share_via_link_edit_permission_read_and_write_label">Скачивание / Просмотр / Закачка</string>
|
||||||
<string name="share_via_link_edit_permission_upload_only_label">Только закачка (перетащить файл сюда)</string>
|
<string name="share_via_link_edit_permission_upload_only_label">Только закачка (перетащить файл сюда)</string>
|
||||||
|
<string name="share_public_link_status_read_only">Скачивание / просмотр</string>
|
||||||
|
<string name="share_public_link_status_read_and_write">Скачивание / просмотр / загрузка</string>
|
||||||
|
<string name="share_public_link_status_upload_only">Только загрузка</string>
|
||||||
|
<string name="share_public_link_status_custom">Особые права</string>
|
||||||
|
<string name="share_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_get_public_link_button">Получить ссылку</string>
|
||||||
<string name="share_with_title">Поделиться с</string>
|
<string name="share_with_title">Поделиться с</string>
|
||||||
<string name="share_with_edit_title">Поделиться с %1$s</string>
|
<string name="share_with_edit_title">Поделиться с %1$s</string>
|
||||||
|
|||||||
@@ -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="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_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="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">Photo backup</string>
|
||||||
<string name="prefs_subsection_picture_uploads_summary">Manage location and behavior of the pictures uploaded automatically</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">Automatic video uploads</string>
|
<string name="prefs_subsection_video_uploads">Video backup</string>
|
||||||
<string name="prefs_subsection_video_uploads_summary">Manage location and behavior of the videos uploaded automatically</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">Picture uploads</string>
|
<string name="prefs_camera_picture_upload">Back up photos</string>
|
||||||
<string name="prefs_camera_picture_upload_summary">Automatically upload pictures taken by camera</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">Picture upload path</string>
|
<string name="prefs_camera_picture_upload_path_title">QSfera destination folder</string>
|
||||||
<string name="prefs_camera_picture_upload_conditions_title">Conditions to execute uploads</string>
|
<string name="prefs_camera_picture_upload_conditions_title">Upload conditions</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_conditions_summary">Backup runs only when the selected conditions are met</string>
|
||||||
<string name="prefs_camera_picture_upload_on_wifi">Upload pictures via wifi only</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">Upload pictures only when charging</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_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">Back up videos</string>
|
||||||
<string name="prefs_camera_video_upload_summary">Automatically upload videos recorded by camera</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">Video upload path</string>
|
<string name="prefs_camera_video_upload_path_title">QSfera destination folder</string>
|
||||||
<string name="prefs_camera_video_upload_on_wifi">Upload videos via wifi only</string>
|
<string name="prefs_camera_video_upload_on_wifi">Never use mobile data for videos</string>
|
||||||
<string name="prefs_camera_video_upload_on_charging">Upload videos only when charging</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_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_advanced">Advanced</string>
|
||||||
<string name="prefs_subsection_notifications">Manage notifications</string>
|
<string name="prefs_subsection_notifications">Manage notifications</string>
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
<string name="update_checking">Checking for updates…</string>
|
<string name="update_checking">Checking for updates…</string>
|
||||||
<string name="update_downloading">Downloading update…</string>
|
<string name="update_downloading">Downloading update…</string>
|
||||||
<string name="update_available_title">Update available</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_title">No updates</string>
|
||||||
<string name="update_not_available_message">You are using the latest available version.</string>
|
<string name="update_not_available_message">You are using the latest available version.</string>
|
||||||
<string name="update_install">Install</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_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_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_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_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="update_release_not_found">Latest Android release was not found.</string>
|
||||||
<string name="pattern_enter_pattern">Please enter your pattern</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">%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_folder">%1$d files, 1 folder</string>
|
||||||
<string name="file_list__footer__files_and_folders">%1$d files, %2$d folders</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_picture_upload_account">QSfera account for photos</string>
|
||||||
<string name="prefs_video_upload_account">Account to upload videos</string>
|
<string name="prefs_video_upload_account">QSfera account for videos</string>
|
||||||
<string name="prefs_camera_upload_source_path_title">Camera folder (%1$s)</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_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_clear_title">Remove selected folders</string>
|
||||||
<string name="prefs_camera_upload_source_paths_empty">No folders selected</string>
|
<string name="prefs_camera_upload_source_paths_empty">No phone folders selected</string>
|
||||||
<string name="confirmation_clear_camera_upload_sources_title">Clear selected folders</string>
|
<string name="confirmation_clear_camera_upload_sources_title">Remove 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="confirmation_clear_camera_upload_sources_message">Backup 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_status_title">Backup status</string>
|
||||||
<string name="prefs_camera_upload_behaviour_title">Original file will be</string>
|
<string name="prefs_camera_upload_status_off">Off</string>
|
||||||
<string name="prefs_camera_upload_last_sync_title">Last synchronization</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="notifications_permission_denied">You can update your preferences in Settings</string>
|
||||||
<string name="upload_copy_files">Copy file</string>
|
<string name="upload_copy_files">Copy file</string>
|
||||||
<string name="upload_move_files">Move 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_only_label">Download / View</string>
|
||||||
<string name="share_via_link_edit_permission_read_and_write_label">Download/View/Upload</string>
|
<string name="share_via_link_edit_permission_read_and_write_label">Download/View/Upload</string>
|
||||||
<string name="share_via_link_edit_permission_upload_only_label">Upload Only (File Drop)</string>
|
<string name="share_via_link_edit_permission_upload_only_label">Upload Only (File Drop)</string>
|
||||||
|
<string name="share_public_link_status_read_only">Download / View</string>
|
||||||
|
<string name="share_public_link_status_read_and_write">Download / View / Upload</string>
|
||||||
|
<string name="share_public_link_status_upload_only">Upload only</string>
|
||||||
|
<string name="share_public_link_status_custom">Custom access</string>
|
||||||
|
<string name="share_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_get_public_link_button">Get link</string>
|
||||||
<string name="share_with_title">Share with</string>
|
<string name="share_with_title">Share with</string>
|
||||||
<string name="share_with_edit_title">Share with %1$s</string>
|
<string name="share_with_edit_title">Share with %1$s</string>
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
app:key="enable_picture_uploads"
|
app:key="enable_picture_uploads"
|
||||||
app:summary="@string/prefs_camera_picture_upload_summary"
|
app:summary="@string/prefs_camera_picture_upload_summary"
|
||||||
app:title="@string/prefs_camera_picture_upload" />
|
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
|
<ListPreference
|
||||||
app:dialogTitle="@string/prefs_picture_upload_account"
|
app:dialogTitle="@string/prefs_picture_upload_account"
|
||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
@@ -64,6 +70,20 @@
|
|||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
app:key="picture_uploads_on_wifi"
|
app:key="picture_uploads_on_wifi"
|
||||||
app:title="@string/prefs_camera_picture_upload_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
|
<CheckBoxPreference
|
||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
app:key="picture_uploads_on_charging"
|
app:key="picture_uploads_on_charging"
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
app:key="enable_video_uploads"
|
app:key="enable_video_uploads"
|
||||||
app:summary="@string/prefs_camera_video_upload_summary"
|
app:summary="@string/prefs_camera_video_upload_summary"
|
||||||
app:title="@string/prefs_camera_video_upload" />
|
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
|
<ListPreference
|
||||||
app:dialogTitle="@string/prefs_video_upload_account"
|
app:dialogTitle="@string/prefs_video_upload_account"
|
||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
@@ -61,7 +67,22 @@
|
|||||||
<CheckBoxPreference
|
<CheckBoxPreference
|
||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
app:key="video_uploads_on_wifi"
|
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" />
|
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
|
<CheckBoxPreference
|
||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
app:key="video_uploads_on_charging"
|
app:key="video_uploads_on_charging"
|
||||||
|
|||||||
+253
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
@@ -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 DB_NAME = "filelist";
|
||||||
public static final String NEW_DB_NAME = "qsfera_database";
|
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() {
|
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_42_43
|
||||||
import eu.qsfera.android.data.migrations.MIGRATION_47_48
|
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_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.OCShareDao
|
||||||
import eu.qsfera.android.data.sharing.shares.db.OCShareEntity
|
import eu.qsfera.android.data.sharing.shares.db.OCShareEntity
|
||||||
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
|
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
|
||||||
@@ -127,7 +130,10 @@ abstract class QSferaDatabase : RoomDatabase() {
|
|||||||
MIGRATION_41_42,
|
MIGRATION_41_42,
|
||||||
MIGRATION_42_43,
|
MIGRATION_42_43,
|
||||||
MIGRATION_47_48,
|
MIGRATION_47_48,
|
||||||
MIGRATION_48_49)
|
MIGRATION_48_49,
|
||||||
|
MIGRATION_49_50,
|
||||||
|
MIGRATION_50_51,
|
||||||
|
MIGRATION_51_52)
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
|
|||||||
+10
@@ -68,9 +68,14 @@ class OCLocalFolderBackupDataSource(
|
|||||||
sourcePath = sourcePath,
|
sourcePath = sourcePath,
|
||||||
uploadPath = uploadPath,
|
uploadPath = uploadPath,
|
||||||
wifiOnly = wifiOnly,
|
wifiOnly = wifiOnly,
|
||||||
|
allowRoaming = allowRoaming,
|
||||||
|
mobileDataLimitBytes = mobileDataLimitBytes,
|
||||||
|
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
|
||||||
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
|
||||||
chargingOnly = chargingOnly,
|
chargingOnly = chargingOnly,
|
||||||
name = name,
|
name = name,
|
||||||
lastSyncTimestamp = lastSyncTimestamp,
|
lastSyncTimestamp = lastSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,8 +88,13 @@ class OCLocalFolderBackupDataSource(
|
|||||||
sourcePath = sourcePath,
|
sourcePath = sourcePath,
|
||||||
uploadPath = uploadPath,
|
uploadPath = uploadPath,
|
||||||
wifiOnly = wifiOnly,
|
wifiOnly = wifiOnly,
|
||||||
|
allowRoaming = allowRoaming,
|
||||||
|
mobileDataLimitBytes = mobileDataLimitBytes,
|
||||||
|
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
|
||||||
|
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
|
||||||
chargingOnly = chargingOnly,
|
chargingOnly = chargingOnly,
|
||||||
lastSyncTimestamp = lastSyncTimestamp,
|
lastSyncTimestamp = lastSyncTimestamp,
|
||||||
|
sourcePathSyncTimestamps = sourcePathSyncTimestamps,
|
||||||
name = name,
|
name = name,
|
||||||
spaceId = spaceId,
|
spaceId = spaceId,
|
||||||
)
|
)
|
||||||
|
|||||||
+5
@@ -29,9 +29,14 @@ data class FolderBackUpEntity(
|
|||||||
val sourcePath: String,
|
val sourcePath: String,
|
||||||
val uploadPath: String,
|
val uploadPath: String,
|
||||||
val wifiOnly: Boolean,
|
val wifiOnly: Boolean,
|
||||||
|
val allowRoaming: Boolean,
|
||||||
|
val mobileDataLimitBytes: Long,
|
||||||
|
val mobileDataPeriodStartTimestamp: Long,
|
||||||
|
val mobileDataBytesQueuedInPeriod: Long,
|
||||||
val chargingOnly: Boolean,
|
val chargingOnly: Boolean,
|
||||||
val name: String,
|
val name: String,
|
||||||
val lastSyncTimestamp: Long,
|
val lastSyncTimestamp: Long,
|
||||||
|
val sourcePathSyncTimestamps: String,
|
||||||
val spaceId: String?,
|
val spaceId: String?,
|
||||||
) {
|
) {
|
||||||
@PrimaryKey(autoGenerate = true) var id: Int = 0
|
@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
|
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_ENABLED, false)) return null
|
||||||
|
|
||||||
return FolderBackUpConfiguration(
|
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),
|
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY, false),
|
||||||
uploadPath = getUploadPathForPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH),
|
uploadPath = getUploadPathForPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH),
|
||||||
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
|
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
|
||||||
behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR),
|
behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR),
|
||||||
lastSyncTimestamp = timestamp,
|
lastSyncTimestamp = timestamp,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = pictureUploadsName,
|
name = pictureUploadsName,
|
||||||
|
allowRoaming = false,
|
||||||
|
mobileDataLimitBytes = 0,
|
||||||
|
mobileDataPeriodStartTimestamp = 0,
|
||||||
|
mobileDataBytesQueuedInPeriod = 0,
|
||||||
chargingOnly = false,
|
chargingOnly = false,
|
||||||
spaceId = null,
|
spaceId = null,
|
||||||
)
|
)
|
||||||
@@ -64,13 +69,18 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
|
|||||||
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_ENABLED, false)) return null
|
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_ENABLED, false)) return null
|
||||||
|
|
||||||
return FolderBackUpConfiguration(
|
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),
|
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY, false),
|
||||||
uploadPath = getUploadPathForPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH),
|
uploadPath = getUploadPathForPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH),
|
||||||
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
|
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
|
||||||
behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR),
|
behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR),
|
||||||
lastSyncTimestamp = timestamp,
|
lastSyncTimestamp = timestamp,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = videoUploadsName,
|
name = videoUploadsName,
|
||||||
|
allowRoaming = false,
|
||||||
|
mobileDataLimitBytes = 0,
|
||||||
|
mobileDataPeriodStartTimestamp = 0,
|
||||||
|
mobileDataBytesQueuedInPeriod = 0,
|
||||||
chargingOnly = false,
|
chargingOnly = false,
|
||||||
spaceId = null,
|
spaceId = null,
|
||||||
)
|
)
|
||||||
@@ -85,7 +95,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getSourcePathForPreference(keyPreference: String): String =
|
private fun getSourcePathForPreference(keyPreference: String): String =
|
||||||
sharedPreferencesProvider.getString(keyPreference, null) ?: ""
|
sharedPreferencesProvider.getString(keyPreference, null).orEmpty()
|
||||||
|
|
||||||
private fun getBehaviorForPreference(keyPreference: String): UploadBehavior {
|
private fun getBehaviorForPreference(keyPreference: String): UploadBehavior {
|
||||||
val storedBehaviour = sharedPreferencesProvider.getString(keyPreference, null) ?: return UploadBehavior.COPY
|
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 ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-1
@@ -19,14 +19,22 @@
|
|||||||
|
|
||||||
package eu.qsfera.android.domain.automaticuploads.model
|
package eu.qsfera.android.domain.automaticuploads.model
|
||||||
|
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
data class FolderBackUpConfiguration(
|
data class FolderBackUpConfiguration(
|
||||||
val accountName: String,
|
val accountName: String,
|
||||||
val behavior: UploadBehavior,
|
val behavior: UploadBehavior,
|
||||||
val sourcePath: String,
|
val sourcePath: String,
|
||||||
val uploadPath: String,
|
val uploadPath: String,
|
||||||
val wifiOnly: Boolean,
|
val wifiOnly: Boolean,
|
||||||
|
val allowRoaming: Boolean,
|
||||||
|
val mobileDataLimitBytes: Long,
|
||||||
|
val mobileDataPeriodStartTimestamp: Long,
|
||||||
|
val mobileDataBytesQueuedInPeriod: Long,
|
||||||
val chargingOnly: Boolean,
|
val chargingOnly: Boolean,
|
||||||
val lastSyncTimestamp: Long,
|
val lastSyncTimestamp: Long,
|
||||||
|
val sourcePathSyncTimestamps: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val spaceId: String?,
|
val spaceId: String?,
|
||||||
) {
|
) {
|
||||||
@@ -34,10 +42,15 @@ data class FolderBackUpConfiguration(
|
|||||||
val isPictureUploads get() = name == pictureUploadsName
|
val isPictureUploads get() = name == pictureUploadsName
|
||||||
val isVideoUploads get() = name == videoUploadsName
|
val isVideoUploads get() = name == videoUploadsName
|
||||||
val sourcePaths get() = parseSourcePaths(sourcePath)
|
val sourcePaths get() = parseSourcePaths(sourcePath)
|
||||||
|
val sourceSyncTimestamps get() = parseSourcePathSyncTimestamps(sourcePathSyncTimestamps)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val pictureUploadsName = "Picture uploads"
|
const val pictureUploadsName = "Picture uploads"
|
||||||
const val videoUploadsName = "Video 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> =
|
fun parseSourcePaths(sourcePath: String): List<String> =
|
||||||
sourcePath
|
sourcePath
|
||||||
@@ -53,7 +66,48 @@ data class FolderBackUpConfiguration(
|
|||||||
.distinct()
|
.distinct()
|
||||||
.joinToString(SOURCE_PATH_SEPARATOR)
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
@@ -11,7 +11,9 @@
|
|||||||
package eu.qsfera.android.domain.automaticuploads.model
|
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.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.parseSourcePaths
|
||||||
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.parseSourcePathSyncTimestamps
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -59,4 +61,33 @@ class FolderBackUpConfigurationTest {
|
|||||||
|
|
||||||
assertEquals("$firstSourcePath\n$secondSourcePath", encodedSourcePaths)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -31,8 +31,13 @@ val OC_BACKUP = FolderBackUpConfiguration(
|
|||||||
sourcePath = "/Photos",
|
sourcePath = "/Photos",
|
||||||
uploadPath = "/Photos",
|
uploadPath = "/Photos",
|
||||||
wifiOnly = true,
|
wifiOnly = true,
|
||||||
|
allowRoaming = false,
|
||||||
|
mobileDataLimitBytes = 0,
|
||||||
|
mobileDataPeriodStartTimestamp = 0,
|
||||||
|
mobileDataBytesQueuedInPeriod = 0,
|
||||||
chargingOnly = true,
|
chargingOnly = true,
|
||||||
lastSyncTimestamp = 1542628397,
|
lastSyncTimestamp = 1542628397,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = "",
|
name = "",
|
||||||
spaceId = null,
|
spaceId = null,
|
||||||
)
|
)
|
||||||
@@ -43,8 +48,13 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity(
|
|||||||
sourcePath = "/Photos",
|
sourcePath = "/Photos",
|
||||||
uploadPath = "/Photos",
|
uploadPath = "/Photos",
|
||||||
wifiOnly = true,
|
wifiOnly = true,
|
||||||
|
allowRoaming = false,
|
||||||
|
mobileDataLimitBytes = 0,
|
||||||
|
mobileDataPeriodStartTimestamp = 0,
|
||||||
|
mobileDataBytesQueuedInPeriod = 0,
|
||||||
chargingOnly = true,
|
chargingOnly = true,
|
||||||
lastSyncTimestamp = 1542628397,
|
lastSyncTimestamp = 1542628397,
|
||||||
|
sourcePathSyncTimestamps = "",
|
||||||
name = "",
|
name = "",
|
||||||
spaceId = null,
|
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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+16
@@ -57,6 +57,8 @@ jobs:
|
|||||||
CRAFT_TARGET: ${{ matrix.target }}
|
CRAFT_TARGET: ${{ matrix.target }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CRAFT_PACKAGE_SYMBOLS: ${{ github.event_name != 'pull_request' }}
|
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 }}
|
container: ${{ matrix.container }}
|
||||||
|
|
||||||
@@ -188,11 +190,25 @@ jobs:
|
|||||||
- name: Package
|
- name: Package
|
||||||
if: matrix.target != 'macos-clang-arm64'
|
if: matrix.target != 'macos-clang-arm64'
|
||||||
run: |
|
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
|
& "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package opencloud/opencloud-desktop
|
||||||
|
|
||||||
- name: Package Appx
|
- name: Package Appx
|
||||||
if: ${{ matrix.os == 'windows-latest' && github.event_name != 'pull_request' }}
|
if: ${{ matrix.os == 'windows-latest' && github.event_name != 'pull_request' }}
|
||||||
run: |
|
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
|
& "${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
|
- name: Prepare artifacts
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ set( APPLICATION_VENDOR "QSfera" )
|
|||||||
set( APPLICATION_ICON_NAME "qsfera" )
|
set( APPLICATION_ICON_NAME "qsfera" )
|
||||||
set( APPLICATION_REV_DOMAIN "eu.qsfera.desktop" )
|
set( APPLICATION_REV_DOMAIN "eu.qsfera.desktop" )
|
||||||
set( APPLICATION_DEFAULT_SERVER_URL "https://qsfera.kusoft.xyz" )
|
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)
|
if(BETA_CHANNEL_BUILD)
|
||||||
set( APPLICATION_NAME "${APPLICATION_NAME} Beta")
|
set( APPLICATION_NAME "${APPLICATION_NAME} Beta")
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -194,13 +194,13 @@ void AccountSettings::slotRemoveCurrentFolder(Folder *folder)
|
|||||||
{
|
{
|
||||||
// TODO: move to qml
|
// TODO: move to qml
|
||||||
qCInfo(lcAccountSettings) << u"Remove Folder " << folder->path();
|
qCInfo(lcAccountSettings) << u"Remove Folder " << folder->path();
|
||||||
auto messageBox = new FontIconMessageBox({Resources::FontIcon::DefaultGlyphes::Question}, tr("Confirm removal of Space"),
|
auto messageBox = new FontIconMessageBox({Resources::FontIcon::DefaultGlyphes::Question}, tr("Confirm removal of folder sync"),
|
||||||
tr("<p>Do you really want to stop syncing the Space »%1«?</p>"
|
tr("<p>Stop syncing <b>%1</b> on this computer?</p>"
|
||||||
"<p><b>Note:</b> This will <b>not</b> delete any files.</p>")
|
"<p><b>Note:</b> This will not delete files in QSfera or on this device.</p>")
|
||||||
.arg(folder->displayName()),
|
.arg(folder->displayName()),
|
||||||
QMessageBox::NoButton, ocApp()->settingsDialog());
|
QMessageBox::NoButton, ocApp()->settingsDialog());
|
||||||
messageBox->setAttribute(Qt::WA_DeleteOnClose);
|
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);
|
messageBox->addButton(tr("Cancel"), QMessageBox::NoRole);
|
||||||
connect(messageBox, &QMessageBox::finished, this, [messageBox, yesButton, folder, this] {
|
connect(messageBox, &QMessageBox::finished, this, [messageBox, yesButton, folder, this] {
|
||||||
if (messageBox->clickedButton() == yesButton) {
|
if (messageBox->clickedButton() == yesButton) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="label">
|
<widget class="QLabel" name="label">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Preparing the account</string>
|
<string>Preparing account</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</widget>
|
</widget>
|
||||||
<widget class="OCC::QmlUtils::OCQuickWidget" name="quickWidget">
|
<widget class="OCC::QmlUtils::OCQuickWidget" name="quickWidget">
|
||||||
<property name="accessibleName">
|
<property name="accessibleName">
|
||||||
<string>Sync connections</string>
|
<string>Synchronized folders</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</widget>
|
</widget>
|
||||||
|
|||||||
@@ -48,8 +48,12 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QCursor>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
|
#include <QGuiApplication>
|
||||||
#include <QMenuBar>
|
#include <QMenuBar>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QWindow>
|
||||||
|
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
using namespace OCC;
|
using namespace OCC;
|
||||||
@@ -97,6 +101,32 @@ void setUpInitialSyncFolder(AccountStatePtr accountStatePtr, bool useVfs)
|
|||||||
Qt::SingleShotConnection);
|
Qt::SingleShotConnection);
|
||||||
accountStatePtr->account()->spacesManager()->checkReady();
|
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
|
QString Application::displayLanguage() const
|
||||||
@@ -244,23 +274,38 @@ AccountStatePtr Application::addNewAccount(AccountPtr newAccount)
|
|||||||
void Application::showSettings()
|
void Application::showSettings()
|
||||||
{
|
{
|
||||||
auto window = ocApp()->settingsDialog();
|
auto window = ocApp()->settingsDialog();
|
||||||
|
ensureWindowIntersectsAvailableScreen(window);
|
||||||
|
|
||||||
|
if (window->windowState().testFlag(Qt::WindowMinimized)) {
|
||||||
|
window->setWindowState(window->windowState() & ~Qt::WindowMinimized);
|
||||||
|
}
|
||||||
|
|
||||||
window->show();
|
window->show();
|
||||||
|
ensureWindowIntersectsAvailableScreen(window);
|
||||||
window->raise();
|
window->raise();
|
||||||
window->activateWindow();
|
window->activateWindow();
|
||||||
|
if (auto *windowHandle = window->windowHandle()) {
|
||||||
|
windowHandle->requestActivate();
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(Q_OS_WIN)
|
#if defined(Q_OS_WIN)
|
||||||
// Windows disallows raising a Window when you're not the active application.
|
// Windows disallows raising a Window when you're not the active application.
|
||||||
// Use a common hack to attach to the active application
|
// Use a common hack to attach to the active application.
|
||||||
const auto activeProcessId = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
|
const auto foregroundWindow = GetForegroundWindow();
|
||||||
if (activeProcessId != qApp->applicationPid()) {
|
const auto foregroundThreadId = foregroundWindow ? GetWindowThreadProcessId(foregroundWindow, nullptr) : 0;
|
||||||
const auto threadId = GetCurrentThreadId();
|
const auto currentThreadId = GetCurrentThreadId();
|
||||||
// don't step here with a debugger...
|
const auto attachedToForegroundThread =
|
||||||
if (AttachThreadInput(threadId, activeProcessId, true)) {
|
foregroundThreadId != 0 && foregroundThreadId != currentThreadId && AttachThreadInput(currentThreadId, foregroundThreadId, true);
|
||||||
|
|
||||||
const auto hwnd = reinterpret_cast<HWND>(window->winId());
|
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);
|
SetForegroundWindow(hwnd);
|
||||||
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
|
||||||
AttachThreadInput(threadId, activeProcessId, false);
|
|
||||||
}
|
if (attachedToForegroundThread) {
|
||||||
|
AttachThreadInput(currentThreadId, foregroundThreadId, false);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
<widget class="QCheckBox" name="autostartCheckBox">
|
<widget class="QCheckBox" name="autostartCheckBox">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Start on Login</string>
|
<string>Start QSfera when I sign in</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<item>
|
<item>
|
||||||
<widget class="QCheckBox" name="syncHiddenFilesCheckBox">
|
<widget class="QCheckBox" name="syncHiddenFilesCheckBox">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Sync hidden files</string>
|
<string>Sync hidden files and folders</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
@@ -108,14 +108,14 @@
|
|||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Show crash reporter</string>
|
<string>Show crash reporter after a crash</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QCheckBox" name="moveToTrashCheckBox">
|
<widget class="QCheckBox" name="moveToTrashCheckBox">
|
||||||
<property name="text">
|
<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>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
@@ -124,14 +124,14 @@
|
|||||||
<item>
|
<item>
|
||||||
<widget class="QPushButton" name="ignoredFilesButton">
|
<widget class="QPushButton" name="ignoredFilesButton">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Edit Ignored Files</string>
|
<string>Ignored files</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QPushButton" name="logSettingsButton">
|
<widget class="QPushButton" name="logSettingsButton">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Log Settings</string>
|
<string>Logs</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ import eu.QSfera.resources 1.0
|
|||||||
Pane {
|
Pane {
|
||||||
id: folderSyncPanel
|
id: folderSyncPanel
|
||||||
// TODO: not cool
|
// TODO: not cool
|
||||||
readonly property real normalSize: 170
|
readonly property real normalSize: 140
|
||||||
readonly property AccountSettings accountSettings: ocContext
|
readonly property AccountSettings accountSettings: ocContext
|
||||||
readonly property OCQuickWidget widget: ocQuickWidget
|
readonly property OCQuickWidget widget: ocQuickWidget
|
||||||
|
|
||||||
spacing: 10
|
spacing: 10
|
||||||
|
|
||||||
Accessible.role: Accessible.List
|
Accessible.role: Accessible.List
|
||||||
Accessible.name: qsTr("Folder Sync")
|
Accessible.name: qsTr("Synchronized folders")
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: widget
|
target: widget
|
||||||
@@ -69,7 +69,7 @@ Pane {
|
|||||||
|
|
||||||
Button {
|
Button {
|
||||||
id: manageAccountButton
|
id: manageAccountButton
|
||||||
text: qsTr("Manage Account")
|
text: qsTr("Account")
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
id: accountMenu
|
id: accountMenu
|
||||||
@@ -89,14 +89,14 @@ Pane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MenuItem {
|
MenuItem {
|
||||||
text: qsTr("Remove")
|
text: qsTr("Remove account")
|
||||||
onTriggered: accountSettings.slotDeleteAccount()
|
onTriggered: accountSettings.slotDeleteAccount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
accountMenu.open();
|
accountMenu.open();
|
||||||
Accessible.announce(qsTr("Account options Menu"));
|
Accessible.announce(qsTr("Account options menu"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.onBacktabPressed: {
|
Keys.onBacktabPressed: {
|
||||||
@@ -352,7 +352,7 @@ Pane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MenuItem {
|
MenuItem {
|
||||||
text: qsTr("Remove Space")
|
text: qsTr("Remove folder sync")
|
||||||
onTriggered: accountSettings.slotRemoveCurrentFolder(folderDelegate.folder)
|
onTriggered: accountSettings.slotRemoveCurrentFolder(folderDelegate.folder)
|
||||||
visible: !folderDelegate.isDeployed
|
visible: !folderDelegate.isDeployed
|
||||||
height: visible ? implicitHeight : 0
|
height: visible ? implicitHeight : 0
|
||||||
@@ -371,7 +371,7 @@ Pane {
|
|||||||
|
|
||||||
Button {
|
Button {
|
||||||
id: addSyncButton
|
id: addSyncButton
|
||||||
text: qsTr("Add Space")
|
text: qsTr("Add folder sync")
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
accountSettings.slotAddFolder();
|
accountSettings.slotAddFolder();
|
||||||
@@ -391,7 +391,7 @@ Pane {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
Label {
|
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
|
visible: accountSettings.accountState.state === AccountState.Connected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Item {
|
|||||||
property alias imageSource: image.source
|
property alias imageSource: image.source
|
||||||
property alias statusSource: statusIcon.source
|
property alias statusSource: statusIcon.source
|
||||||
|
|
||||||
|
property real imageSize: 96
|
||||||
property real spacing: 10
|
property real spacing: 10
|
||||||
|
|
||||||
default property alias content: colLayout.data
|
default property alias content: colLayout.data
|
||||||
@@ -39,15 +40,20 @@ Item {
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Accessible.ignored: true
|
Accessible.ignored: true
|
||||||
Layout.preferredHeight: normalSize - 20
|
Layout.preferredHeight: imageSize
|
||||||
Layout.preferredWidth: normalSize - 20
|
Layout.preferredWidth: imageSize
|
||||||
Layout.alignment: Qt.AlignTop
|
Layout.alignment: Qt.AlignTop
|
||||||
|
|
||||||
color: spaceDelegate.palette.alternateBase
|
color: spaceDelegate.palette.alternateBase
|
||||||
|
radius: 6
|
||||||
|
border.color: spaceDelegate.palette.mid
|
||||||
|
border.width: 1
|
||||||
|
clip: true
|
||||||
|
|
||||||
Image {
|
Image {
|
||||||
id: image
|
id: image
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
fillMode: Image.PreserveAspectFit
|
fillMode: Image.PreserveAspectFit
|
||||||
sourceSize.width: width
|
sourceSize.width: width
|
||||||
sourceSize.height: height
|
sourceSize.height: height
|
||||||
@@ -74,7 +80,7 @@ Item {
|
|||||||
Accessible.ignored: true
|
Accessible.ignored: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pointSize: 15
|
font.pointSize: 13
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import eu.QSfera.libsync 1.0
|
|||||||
Pane {
|
Pane {
|
||||||
id: spacesView
|
id: spacesView
|
||||||
// TODO: not cool
|
// TODO: not cool
|
||||||
readonly property real normalSize: 170
|
readonly property real normalSize: 140
|
||||||
|
|
||||||
readonly property SpacesBrowser spacesBrowser: ocContext
|
readonly property SpacesBrowser spacesBrowser: ocContext
|
||||||
readonly property OCQuickWidget widget: ocQuickWidget
|
readonly property OCQuickWidget widget: ocQuickWidget
|
||||||
|
|||||||
@@ -24,11 +24,21 @@
|
|||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
|
|
||||||
namespace OCC {
|
namespace OCC {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr auto showSettingsAfterTrayEventDelay = 100;
|
||||||
|
|
||||||
|
void showSettingsAfterTrayEvent()
|
||||||
|
{
|
||||||
|
auto *app = ocApp();
|
||||||
|
QTimer::singleShot(showSettingsAfterTrayEventDelay, app, [app] { app->showSettings(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Systray::Systray(QObject *parent)
|
Systray::Systray(QObject *parent)
|
||||||
: QSystemTrayIcon(parent)
|
: QSystemTrayIcon(parent)
|
||||||
@@ -36,7 +46,7 @@ Systray::Systray(QObject *parent)
|
|||||||
connect(this, &QSystemTrayIcon::activated, this, [](QSystemTrayIcon::ActivationReason reason) {
|
connect(this, &QSystemTrayIcon::activated, this, [](QSystemTrayIcon::ActivationReason reason) {
|
||||||
// Left click
|
// Left click
|
||||||
if (reason == QSystemTrayIcon::Trigger) {
|
if (reason == QSystemTrayIcon::Trigger) {
|
||||||
ocApp()->showSettings();
|
showSettingsAfterTrayEvent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
connect(AccountManager::instance(), &AccountManager::accountAdded, this,
|
connect(AccountManager::instance(), &AccountManager::accountAdded, this,
|
||||||
@@ -143,7 +153,8 @@ void Systray::computeContextMenu()
|
|||||||
Q_ASSERT(!contextMenu());
|
Q_ASSERT(!contextMenu());
|
||||||
auto *menu = new QMenu(Theme::instance()->appNameGUI());
|
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 *pauseResume = new QAction(menu);
|
||||||
auto updatePauseResumeAction = [pauseResume] {
|
auto updatePauseResumeAction = [pauseResume] {
|
||||||
pauseResume->setText(FolderMan::instance()->scheduler()->isRunning() ? tr("Pause synchronizations") : tr("Resume synchronizations"));
|
pauseResume->setText(FolderMan::instance()->scheduler()->isRunning() ? tr("Pause synchronizations") : tr("Resume synchronizations"));
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ void ConfigFile::setSkipUpdateCheck(bool skip)
|
|||||||
QString ConfigFile::updateChannel() const
|
QString ConfigFile::updateChannel() const
|
||||||
{
|
{
|
||||||
auto settings = makeQSettings();
|
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)
|
void ConfigFile::setUpdateChannel(const QString &channel)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
#include "csync.h"
|
#include "csync.h"
|
||||||
#include "syncfileitem.h"
|
#include "syncfileitem.h"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
|
||||||
#ifdef Q_OS_WIN32
|
#ifdef Q_OS_WIN32
|
||||||
@@ -74,7 +75,9 @@ time_t FileSystem::fileTimeToTime_t(std::filesystem::file_time_type fileTime)
|
|||||||
#ifdef HAS_CLOCK_CAST
|
#ifdef HAS_CLOCK_CAST
|
||||||
return std::chrono::system_clock::to_time_t(std::chrono::clock_cast<std::chrono::system_clock>(fileTime));
|
return std::chrono::system_clock::to_time_t(std::chrono::clock_cast<std::chrono::system_clock>(fileTime));
|
||||||
#else
|
#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);
|
return std::chrono::system_clock::to_time_t(systemTime);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -83,7 +86,10 @@ std::filesystem::file_time_type FileSystem::time_tToFileTime(time_t fileTime)
|
|||||||
#ifdef HAS_CLOCK_CAST
|
#ifdef HAS_CLOCK_CAST
|
||||||
return std::chrono::clock_cast<std::chrono::file_clock>(std::chrono::system_clock::from_time_t(fileTime));
|
return std::chrono::clock_cast<std::chrono::file_clock>(std::chrono::system_clock::from_time_t(fileTime));
|
||||||
#else
|
#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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
QPixmap Resources::pixmap(const QSize &requestedSize, const QIcon &icon, QIcon::Mode mode, QSize *outSize)
|
||||||
{
|
{
|
||||||
Q_ASSERT(requestedSize.isValid());
|
QSize actualSize = requestedSize.isValid() && !requestedSize.isEmpty() ? requestedSize : icon.actualSize(minIconSize);
|
||||||
QSize actualSize = requestedSize.isValid() ? requestedSize : icon.availableSizes().constFirst();
|
if (!actualSize.isValid() || actualSize.isEmpty()) {
|
||||||
|
actualSize = minIconSize;
|
||||||
|
}
|
||||||
if (outSize) {
|
if (outSize) {
|
||||||
*outSize = actualSize;
|
*outSize = actualSize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ qsfera_add_test(ConcatUrl)
|
|||||||
qsfera_add_test(XmlParse)
|
qsfera_add_test(XmlParse)
|
||||||
qsfera_add_test(ChecksumValidator)
|
qsfera_add_test(ChecksumValidator)
|
||||||
qsfera_add_test(ConnectionValidator)
|
qsfera_add_test(ConnectionValidator)
|
||||||
|
qsfera_add_test(ConfigFile)
|
||||||
|
|
||||||
|
|
||||||
# TODO: we need keychain access for this test
|
# 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
|
And user "Alice" has set up a client with default settings
|
||||||
When the user removes the connection for user "Alice"
|
When the user removes the connection for user "Alice"
|
||||||
Then the settings tab should have the following options in the general section:
|
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 folder "large-folder" should exist on the file system
|
||||||
And the file "testFile.txt" 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
|
And user "Alice" has set up a client with default settings
|
||||||
When the user opens the settings tab
|
When the user opens the settings tab
|
||||||
Then the settings tab should have the following options in the general section:
|
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:
|
And the settings tab should have the following options in the advanced section:
|
||||||
| Sync hidden files |
|
| Sync hidden files and folders |
|
||||||
| Edit ignored files |
|
| Ignored files |
|
||||||
| Log settings |
|
| Logs |
|
||||||
And the settings tab should have the following options in the network section:
|
And the settings tab should have the following options in the network section:
|
||||||
| Download Bandwidth |
|
| Download Bandwidth |
|
||||||
| Upload Bandwidth |
|
| Upload Bandwidth |
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ from helpers.SyncHelper import wait_for
|
|||||||
|
|
||||||
class AccountSetting:
|
class AccountSetting:
|
||||||
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
|
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}")
|
ACCOUNT_MENU = SimpleNamespace(by=By.NAME, selector="{menu_item}")
|
||||||
CONFIRM_REMOVE_CONNECTION_BUTTON = SimpleNamespace(
|
CONFIRM_REMOVE_CONNECTION_BUTTON = SimpleNamespace(
|
||||||
by=By.NAME, selector="Remove connection"
|
by=By.NAME, selector="Remove connection"
|
||||||
)
|
)
|
||||||
ACCOUNT_CONNECTION_LABEL = SimpleNamespace(
|
ACCOUNT_CONNECTION_LABEL = SimpleNamespace(
|
||||||
by=By.XPATH,
|
by=By.XPATH,
|
||||||
selector="//list[@name='Folder Sync']//label",
|
selector="//list[@name='Synchronized folders']//label",
|
||||||
)
|
)
|
||||||
LOG_BROWSER_WINDOW = SimpleNamespace(by=None, selector=None)
|
LOG_BROWSER_WINDOW = SimpleNamespace(by=None, selector=None)
|
||||||
ACCOUNT_LOADING = SimpleNamespace(by=None, selector=None)
|
ACCOUNT_LOADING = SimpleNamespace(by=None, selector=None)
|
||||||
@@ -50,7 +50,7 @@ class AccountSetting:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def remove_account_connection():
|
def remove_account_connection():
|
||||||
AccountSetting.account_action("Remove")
|
AccountSetting.account_action("Remove account")
|
||||||
app().find_element(
|
app().find_element(
|
||||||
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.by,
|
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.by,
|
||||||
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.selector,
|
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.selector,
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ class Settings:
|
|||||||
ABOUT_DIALOG = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | About]")
|
ABOUT_DIALOG = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | About]")
|
||||||
ABOUT_DIALOG_OK_BUTTON = SimpleNamespace(by=By.NAME, selector="OK")
|
ABOUT_DIALOG_OK_BUTTON = SimpleNamespace(by=By.NAME, selector="OK")
|
||||||
GENERAL_SETTING_START_ON_LOGIN = SimpleNamespace(
|
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(
|
GENERAL_SETTING_LANGUAGE = SimpleNamespace(
|
||||||
by=By.XPATH, selector="//panel/label[@name='Language']"
|
by=By.XPATH, selector="//panel/label[@name='Language']"
|
||||||
)
|
)
|
||||||
ADVANCED_SETTING_SYNC_HIDDEN_FILES = SimpleNamespace(
|
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(
|
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(
|
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(
|
NETWORK_SETTING_DOWNLOAD_BANDWIDTH = SimpleNamespace(
|
||||||
by=By.XPATH, selector="//panel[@name='Download Bandwidth']"
|
by=By.XPATH, selector="//panel[@name='Download Bandwidth']"
|
||||||
@@ -50,7 +50,7 @@ class Settings:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def has_general_setting(setting):
|
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
|
locator = Settings.GENERAL_SETTING_START_ON_LOGIN
|
||||||
elif setting.lower() == "language":
|
elif setting.lower() == "language":
|
||||||
locator = Settings.GENERAL_SETTING_LANGUAGE
|
locator = Settings.GENERAL_SETTING_LANGUAGE
|
||||||
@@ -60,11 +60,11 @@ class Settings:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def has_advanced_setting(setting):
|
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
|
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
|
locator = Settings.ADVANCED_SETTING_EDIT_IGNORED_FILES
|
||||||
elif setting.lower() == "log settings":
|
elif setting.lower() == "logs":
|
||||||
locator = Settings.ADVANCED_SETTING_LOG_SETTINGS
|
locator = Settings.ADVANCED_SETTING_LOG_SETTINGS
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown advanced setting: {setting}")
|
raise ValueError(f"Unknown advanced setting: {setting}")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from helpers.AppHelper import app
|
|||||||
|
|
||||||
class SyncConnection:
|
class SyncConnection:
|
||||||
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
|
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
|
||||||
by=By.NAME, selector="Sync connections"
|
by=By.NAME, selector="Synchronized folders"
|
||||||
)
|
)
|
||||||
FOLDER_SYNC_CONNECTION_MENU_BUTTON = SimpleNamespace(
|
FOLDER_SYNC_CONNECTION_MENU_BUTTON = SimpleNamespace(
|
||||||
by=By.NAME,
|
by=By.NAME,
|
||||||
@@ -18,7 +18,7 @@ class SyncConnection:
|
|||||||
SELECTIVE_SYNC_APPLY_BUTTON = SimpleNamespace(by=None, selector=None)
|
SELECTIVE_SYNC_APPLY_BUTTON = SimpleNamespace(by=None, selector=None)
|
||||||
CANCEL_FOLDER_SYNC_CONNECTION_DIALOG = SimpleNamespace(by=None, selector=None)
|
CANCEL_FOLDER_SYNC_CONNECTION_DIALOG = SimpleNamespace(by=None, selector=None)
|
||||||
CONFIRM_FOLDER_SYNC_CONNECTION_REMOVE = SimpleNamespace(
|
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)
|
PERMISSION_ERROR_LABEL = SimpleNamespace(by=None, selector=None)
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ class SyncConnection:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def remove_folder_sync_connection():
|
def remove_folder_sync_connection():
|
||||||
SyncConnection.perform_action("Remove Space")
|
SyncConnection.perform_action("Remove folder sync")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cancel_folder_sync_connection_removal():
|
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.
|
- 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.
|
- 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
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -230,12 +230,12 @@ File synchronization desktop utility.</source>
|
|||||||
<name>FolderDelegate</name>
|
<name>FolderDelegate</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
|
||||||
<source>Folder Sync</source>
|
<source>Synchronized folders</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
|
||||||
<source>Manage Account</source>
|
<source>Account</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
@@ -255,12 +255,12 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
|
||||||
<source>Remove</source>
|
<source>Remove account</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
|
||||||
<source>Account options Menu</source>
|
<source>Account options menu</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
@@ -290,7 +290,7 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
|
||||||
<source>Remove Space</source>
|
<source>Remove folder sync</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
@@ -300,12 +300,12 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
|
||||||
<source>Add Space</source>
|
<source>Add folder sync</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="394"/>
|
<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>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
@@ -601,12 +601,12 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.ui" line="58"/>
|
<location filename="../src/gui/accountsettings.ui" line="58"/>
|
||||||
<source>Preparing the account</source>
|
<source>Preparing account</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.ui" line="86"/>
|
<location filename="../src/gui/accountsettings.ui" line="86"/>
|
||||||
<source>Sync connections</source>
|
<source>Synchronized folders</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
@@ -622,17 +622,17 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="197"/>
|
<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>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="198"/>
|
<location filename="../src/gui/accountsettings.cpp" line="198"/>
|
||||||
<source><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></source>
|
<source><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></source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="203"/>
|
<location filename="../src/gui/accountsettings.cpp" line="203"/>
|
||||||
<source>Remove Space</source>
|
<source>Remove folder sync</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
@@ -1269,7 +1269,7 @@ Please consider removing this folder from the account and adding it again.</sour
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="80"/>
|
<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>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
@@ -1284,27 +1284,27 @@ Please consider removing this folder from the account and adding it again.</sour
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="98"/>
|
<location filename="../src/gui/generalsettings.ui" line="98"/>
|
||||||
<source>Sync hidden files</source>
|
<source>Sync hidden files and folders</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="111"/>
|
<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>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="118"/>
|
<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>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="127"/>
|
<location filename="../src/gui/generalsettings.ui" line="127"/>
|
||||||
<source>Edit Ignored Files</source>
|
<source>Ignored files</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="134"/>
|
<location filename="../src/gui/generalsettings.ui" line="134"/>
|
||||||
<source>Log Settings</source>
|
<source>Logs</source>
|
||||||
<translation type="unfinished"></translation>
|
<translation type="unfinished"></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
|
|||||||
@@ -231,13 +231,13 @@ File synchronization desktop utility.</source>
|
|||||||
<name>FolderDelegate</name>
|
<name>FolderDelegate</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
|
||||||
<source>Folder Sync</source>
|
<source>Synchronized folders</source>
|
||||||
<translation>Синхронизация папки</translation>
|
<translation>Синхронизированные папки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
|
||||||
<source>Manage Account</source>
|
<source>Account</source>
|
||||||
<translation>Управление аккаунтом</translation>
|
<translation>Аккаунт</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="78"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="78"/>
|
||||||
@@ -256,13 +256,13 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
|
||||||
<source>Remove</source>
|
<source>Remove account</source>
|
||||||
<translation>Удалить</translation>
|
<translation>Удалить аккаунт</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
|
||||||
<source>Account options Menu</source>
|
<source>Account options menu</source>
|
||||||
<translation>Меню настроек аккаунта</translation>
|
<translation>Меню аккаунта</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="332"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="332"/>
|
||||||
@@ -291,8 +291,8 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
|
||||||
<source>Remove Space</source>
|
<source>Remove folder sync</source>
|
||||||
<translation>Удалить Пространство</translation>
|
<translation>Удалить синхронизацию папки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="362"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="362"/>
|
||||||
@@ -301,13 +301,13 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
|
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
|
||||||
<source>Add Space</source>
|
<source>Add folder sync</source>
|
||||||
<translation>Добавить Пространство</translation>
|
<translation>Добавить синхронизацию папки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/qml/FolderDelegate.qml" line="394"/>
|
<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>Вы синхронизируете %1 из %2 Пространств</translation>
|
<translation>Синхронизированные папки: %1 из %2</translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
<context>
|
<context>
|
||||||
@@ -602,13 +602,13 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.ui" line="58"/>
|
<location filename="../src/gui/accountsettings.ui" line="58"/>
|
||||||
<source>Preparing the account</source>
|
<source>Preparing account</source>
|
||||||
<translation>Подготовка профиля</translation>
|
<translation>Подготовка аккаунта</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.ui" line="86"/>
|
<location filename="../src/gui/accountsettings.ui" line="86"/>
|
||||||
<source>Sync connections</source>
|
<source>Synchronized folders</source>
|
||||||
<translation>Подключения для синхронизации</translation>
|
<translation>Синхронизированные папки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="204"/>
|
<location filename="../src/gui/accountsettings.cpp" line="204"/>
|
||||||
@@ -623,18 +623,18 @@ File synchronization desktop utility.</source>
|
|||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="197"/>
|
<location filename="../src/gui/accountsettings.cpp" line="197"/>
|
||||||
<source>Confirm removal of Space</source>
|
<source>Confirm removal of folder sync</source>
|
||||||
<translation>Подтвердите удаление Пространства</translation>
|
<translation>Подтвердите удаление синхронизации папки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="198"/>
|
<location filename="../src/gui/accountsettings.cpp" line="198"/>
|
||||||
<source><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></source>
|
<source><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></source>
|
||||||
<translation><p>Вы действительно хотите остановить синхронизацию Пространства »%1«? Примечание:</b> Это <b>не</b> приведет к удалению файлов.</p></translation>
|
<translation><p>Остановить синхронизацию <b>%1</b> на этом компьютере?</p><p><b>Важно:</b> Файлы в QSfera и на этом устройстве не будут удалены.</p></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="203"/>
|
<location filename="../src/gui/accountsettings.cpp" line="203"/>
|
||||||
<source>Remove Space</source>
|
<source>Remove folder sync</source>
|
||||||
<translation>Удалить Пространство</translation>
|
<translation>Удалить синхронизацию папки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/accountsettings.cpp" line="240"/>
|
<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>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="80"/>
|
<location filename="../src/gui/generalsettings.ui" line="80"/>
|
||||||
<source>Start on Login</source>
|
<source>Start QSfera when I sign in</source>
|
||||||
<translation>Запускать при входе в систему</translation>
|
<translation>Запускать QSfera при входе в систему</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="52"/>
|
<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>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="98"/>
|
<location filename="../src/gui/generalsettings.ui" line="98"/>
|
||||||
<source>Sync hidden files</source>
|
<source>Sync hidden files and folders</source>
|
||||||
<translation>Синхронизировать скрытые файлы</translation>
|
<translation>Синхронизировать скрытые файлы и папки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="111"/>
|
<location filename="../src/gui/generalsettings.ui" line="111"/>
|
||||||
<source>Show crash reporter</source>
|
<source>Show crash reporter after a crash</source>
|
||||||
<translation>Показать отчет об ошибке</translation>
|
<translation>Показывать отчет о сбое после ошибки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="118"/>
|
<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>Перемещать файлы, удаленные через серверный доступ, в локальную корзину вместо их полного удаления</translation>
|
<translation>Перемещать удаленные на сервере файлы в локальную корзину вместо удаления</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="127"/>
|
<location filename="../src/gui/generalsettings.ui" line="127"/>
|
||||||
<source>Edit Ignored Files</source>
|
<source>Ignored files</source>
|
||||||
<translation>Изменить игнорируемые файлф</translation>
|
<translation>Игнорируемые файлы</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="134"/>
|
<location filename="../src/gui/generalsettings.ui" line="134"/>
|
||||||
<source>Log Settings</source>
|
<source>Logs</source>
|
||||||
<translation>Настройки Логирования</translation>
|
<translation>Логи</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../src/gui/generalsettings.ui" line="161"/>
|
<location filename="../src/gui/generalsettings.ui" line="161"/>
|
||||||
|
|||||||
+4
-10
@@ -37,17 +37,11 @@ go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.j
|
|||||||
|
|
||||||
## Publish to Argus
|
## 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`
|
Build the APK, then run:
|
||||||
* `PUT /api/admin/apps/{slug}`
|
|
||||||
* `POST /api/admin/apps/{slug}/releases`
|
|
||||||
|
|
||||||
Set credentials in the current shell, then run:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:ARGUS_USERNAME='admin'
|
|
||||||
$env:ARGUS_PASSWORD='<password>'
|
|
||||||
.\scripts\publish-argus.ps1
|
.\scripts\publish-argus.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,6 +51,6 @@ Default publish metadata:
|
|||||||
* package: `eu.qsfera.keeper`
|
* package: `eu.qsfera.keeper`
|
||||||
* name: `QKeep`
|
* name: `QKeep`
|
||||||
* Android label: `КуЗаметки`
|
* Android label: `КуЗаметки`
|
||||||
* version: `0.1.21`
|
* version: `0.1.22`
|
||||||
* Android version code: `22`
|
* Android version code: `23`
|
||||||
* channel: `stable`
|
* channel: `stable`
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId "eu.qsfera.keeper"
|
applicationId "eu.qsfera.keeper"
|
||||||
minSdkVersion sdkMinVersion
|
minSdkVersion sdkMinVersion
|
||||||
targetSdkVersion sdkTargetVersion
|
targetSdkVersion sdkTargetVersion
|
||||||
versionCode 22
|
versionCode 23
|
||||||
versionName "0.1.21"
|
versionName "0.1.22"
|
||||||
|
|
||||||
def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes"
|
def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes"
|
||||||
def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz"
|
def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz"
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ enum class BodyTextStyle {
|
|||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var authPreferences: AuthPreferences
|
private lateinit var authPreferences: AuthPreferences
|
||||||
private lateinit var keepSettings: KeepSettings
|
private lateinit var keepSettings: KeepSettings
|
||||||
|
private lateinit var versionCleanup: VersionCleanup
|
||||||
private lateinit var authClient: QsferaAuthClient
|
private lateinit var authClient: QsferaAuthClient
|
||||||
private lateinit var repository: NotesRepository
|
private lateinit var repository: NotesRepository
|
||||||
private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL)
|
private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL)
|
||||||
@@ -183,10 +184,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
authPreferences = AuthPreferences(applicationContext)
|
authPreferences = AuthPreferences(applicationContext)
|
||||||
keepSettings = KeepSettings(applicationContext)
|
keepSettings = KeepSettings(applicationContext)
|
||||||
|
versionCleanup = VersionCleanup(applicationContext)
|
||||||
authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL)
|
authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL)
|
||||||
repository = NotesRepository(applicationContext, authPreferences, authClient)
|
repository = NotesRepository(applicationContext, authPreferences, authClient)
|
||||||
configureSystemBars()
|
configureSystemBars()
|
||||||
VersionCleanup(applicationContext).cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME)
|
versionCleanup.cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME)
|
||||||
ReminderScheduler(applicationContext).ensureNotificationChannel()
|
ReminderScheduler(applicationContext).ensureNotificationChannel()
|
||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
|
|
||||||
@@ -799,12 +801,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
checkButton.isEnabled = true
|
checkButton.isEnabled = true
|
||||||
updateStatus.text = result.fold(
|
updateStatus.text = result.fold(
|
||||||
onSuccess = { latest ->
|
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
|
availableUpdate = latest
|
||||||
installButton.visibility = View.VISIBLE
|
installButton.visibility = View.VISIBLE
|
||||||
getString(R.string.update_available, latest.version, latest.androidVersionCode)
|
getString(R.string.update_available, latest.version)
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.up_to_date, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
|
getString(R.string.up_to_date, installedVersion)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
@@ -827,6 +832,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
installButton.isEnabled = true
|
installButton.isEnabled = true
|
||||||
result
|
result
|
||||||
.onSuccess { apk ->
|
.onSuccess { apk ->
|
||||||
|
versionCleanup.markUpdateStaged(update.version)
|
||||||
updateStatus.text = getString(R.string.opening_installer)
|
updateStatus.text = getString(R.string.opening_installer)
|
||||||
installApk(apk)
|
installApk(apk)
|
||||||
}
|
}
|
||||||
@@ -3099,13 +3105,13 @@ data class PendingOAuth(
|
|||||||
|
|
||||||
data class AppUpdateInfo(
|
data class AppUpdateInfo(
|
||||||
val version: String,
|
val version: String,
|
||||||
val androidVersionCode: Int,
|
|
||||||
val downloadUrl: String,
|
val downloadUrl: String,
|
||||||
val sha256: String?,
|
val sha256: String,
|
||||||
|
val packageSizeBytes: Long?,
|
||||||
)
|
)
|
||||||
|
|
||||||
class AppUpdateClient(private val manifestUrl: String) {
|
class AppUpdateClient(private val manifestUrl: String) {
|
||||||
fun latest(): AppUpdateInfo {
|
fun latest(): AppUpdateInfo? {
|
||||||
val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply {
|
val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = "GET"
|
requestMethod = "GET"
|
||||||
connectTimeout = 8000
|
connectTimeout = 8000
|
||||||
@@ -3116,16 +3122,22 @@ class AppUpdateClient(private val manifestUrl: String) {
|
|||||||
return try {
|
return try {
|
||||||
val response = if (connection.responseCode in 200..299) {
|
val response = if (connection.responseCode in 200..299) {
|
||||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
} else if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||||
|
return null
|
||||||
} else {
|
} else {
|
||||||
val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||||
throw IOException("HTTP ${connection.responseCode}: $errorText")
|
throw IOException("HTTP ${connection.responseCode}: $errorText")
|
||||||
}
|
}
|
||||||
val release = JSONObject(response).getJSONObject("release")
|
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(
|
AppUpdateInfo(
|
||||||
version = release.getString("version"),
|
version = release.getString("version"),
|
||||||
androidVersionCode = release.getInt("androidVersionCode"),
|
|
||||||
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
|
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
|
||||||
sha256 = release.optString("sha256").takeIf { it.isNotBlank() }
|
sha256 = sha256,
|
||||||
|
packageSizeBytes = release.optLong("packageSizeBytes", -1L).takeIf { it >= 0L }
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
@@ -3139,7 +3151,10 @@ class AppUpdateClient(private val manifestUrl: String) {
|
|||||||
updateDir.mkdirs()
|
updateDir.mkdirs()
|
||||||
}
|
}
|
||||||
val apk = File(updateDir, "keeper-android-${update.version}.apk")
|
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")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
var downloadedBytes = 0L
|
||||||
val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply {
|
val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = "GET"
|
requestMethod = "GET"
|
||||||
connectTimeout = 8000
|
connectTimeout = 8000
|
||||||
@@ -3153,25 +3168,38 @@ class AppUpdateClient(private val manifestUrl: String) {
|
|||||||
throw IOException("HTTP ${connection.responseCode}: $errorText")
|
throw IOException("HTTP ${connection.responseCode}: $errorText")
|
||||||
}
|
}
|
||||||
connection.inputStream.use { input ->
|
connection.inputStream.use { input ->
|
||||||
apk.outputStream().use { output ->
|
tempApk.outputStream().use { output ->
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
while (true) {
|
while (true) {
|
||||||
val read = input.read(buffer)
|
val read = input.read(buffer)
|
||||||
if (read < 0) break
|
if (read < 0) break
|
||||||
digest.update(buffer, 0, read)
|
digest.update(buffer, 0, read)
|
||||||
output.write(buffer, 0, read)
|
output.write(buffer, 0, read)
|
||||||
|
downloadedBytes += read
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
tempApk.delete()
|
||||||
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
val actualSha256 = digest.digest().toHexString()
|
val actualSha256 = digest.digest().toHexString()
|
||||||
val expectedSha256 = update.sha256
|
if (!update.sha256.equals(actualSha256, ignoreCase = true)) {
|
||||||
if (expectedSha256 != null && !expectedSha256.equals(actualSha256, ignoreCase = true)) {
|
tempApk.delete()
|
||||||
apk.delete()
|
|
||||||
throw IOException("Downloaded APK checksum mismatch")
|
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
|
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) {
|
class VersionCleanup(private val context: Context) {
|
||||||
private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE)
|
private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
@@ -3193,10 +3247,26 @@ class VersionCleanup(private val context: Context) {
|
|||||||
cleanupOldPackageFiles(currentVersionName)
|
cleanupOldPackageFiles(currentVersionName)
|
||||||
preferences.edit()
|
preferences.edit()
|
||||||
.putInt(KEY_LAST_VERSION_CODE, currentVersionCode)
|
.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()
|
.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) {
|
fun cleanupOldPackageFiles(currentVersionName: String) {
|
||||||
packageFileRoots().forEach { root ->
|
packageFileRoots().forEach { root ->
|
||||||
if (!root.exists()) return@forEach
|
if (!root.exists()) return@forEach
|
||||||
@@ -3229,6 +3299,9 @@ class VersionCleanup(private val context: Context) {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val KEY_LAST_VERSION_CODE = "last_version_code"
|
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="checking_updates">Проверяю обновления…</string>
|
||||||
<string name="downloading_update">Скачиваю обновление…</string>
|
<string name="downloading_update">Скачиваю обновление…</string>
|
||||||
<string name="opening_installer">Открываю установщик Android…</string>
|
<string name="opening_installer">Открываю установщик Android…</string>
|
||||||
<string name="up_to_date">Установлена актуальная версия %1$s (%2$d).</string>
|
<string name="up_to_date">Установлена актуальная версия %1$s.</string>
|
||||||
<string name="update_available">Доступна версия %1$s (%2$d).</string>
|
<string name="no_update_available">В Argus нет релиза для этого приложения.</string>
|
||||||
|
<string name="update_available">Доступна версия %1$s.</string>
|
||||||
<string name="update_check_failed">Не удалось проверить обновления.</string>
|
<string name="update_check_failed">Не удалось проверить обновления.</string>
|
||||||
<string name="update_download_failed">Не удалось скачать обновление.</string>
|
<string name="update_download_failed">Не удалось скачать обновление.</string>
|
||||||
<string name="allow_update_install">Разрешите установку из этого источника и повторите установку обновления.</string>
|
<string name="allow_update_install">Разрешите установку из этого источника и повторите установку обновления.</string>
|
||||||
|
|||||||
@@ -1,99 +1,231 @@
|
|||||||
param(
|
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]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }),
|
||||||
[string]$Username = $env:ARGUS_USERNAME,
|
[string]$ArgusData = $(if ($env:ARGUS_DATA) { $env:ARGUS_DATA } else { "/srv/argus-data" }),
|
||||||
[string]$Password = $env:ARGUS_PASSWORD,
|
|
||||||
[string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk",
|
[string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk",
|
||||||
[string]$Slug = "keeper-android",
|
[string]$Slug = "keeper-android",
|
||||||
[string]$Name = "QKeep",
|
[string]$Name = "QKeep",
|
||||||
[string]$Summary = "Cloud notes app for QSfera with local storage and sync.",
|
[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]$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]$AndroidPackageName = "eu.qsfera.keeper",
|
[string]$RepositoryUrl = "",
|
||||||
[string]$Version = "0.1.21",
|
[string]$HomepageUrl = "",
|
||||||
[int]$AndroidVersionCode = 22,
|
[string]$Version = "0.1.22",
|
||||||
[string]$Channel = "stable",
|
[string]$Channel = "stable",
|
||||||
[string]$Platform = "android",
|
[string]$Platform = "android",
|
||||||
[string]$PackageKind = "apk",
|
[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"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) {
|
function ConvertTo-Base64Utf8([string]$Value) {
|
||||||
throw "Set ARGUS_USERNAME and ARGUS_PASSWORD before publishing."
|
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
|
$resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path
|
||||||
$baseUri = [Uri]($ArgusBaseUrl.TrimEnd("/"))
|
$sshTarget = "$SshUser@$SshHost"
|
||||||
$slugEscaped = [Uri]::EscapeDataString($Slug)
|
$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()
|
$nameB64 = ConvertTo-Base64Utf8 $Name
|
||||||
$handler.CookieContainer = [System.Net.CookieContainer]::new()
|
$summaryB64 = ConvertTo-Base64Utf8 $Summary
|
||||||
$client = [System.Net.Http.HttpClient]::new($handler)
|
$descriptionB64 = ConvertTo-Base64Utf8 $Description
|
||||||
$client.BaseAddress = $baseUri
|
$repositoryUrlB64 = ConvertTo-Base64Utf8 $RepositoryUrl
|
||||||
|
$homepageUrlB64 = ConvertTo-Base64Utf8 $HomepageUrl
|
||||||
|
$notesB64 = ConvertTo-Base64Utf8 $Notes
|
||||||
|
|
||||||
function New-JsonContent([object]$Value) {
|
$remoteScript = @"
|
||||||
$json = $Value | ConvertTo-Json -Depth 8
|
set -euo pipefail
|
||||||
return [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, "application/json")
|
|
||||||
|
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) {
|
Write-Output "Published $Slug $Version to $manifestUrl"
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -61,6 +61,7 @@ protogen/buf.sha1.lock
|
|||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
.env
|
.env
|
||||||
|
.env.server
|
||||||
.envrc
|
.envrc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -26,15 +26,21 @@ RUN make node-generate-prod
|
|||||||
|
|
||||||
FROM golang:1.25-alpine AS build
|
FROM golang:1.25-alpine AS build
|
||||||
ARG TARGETARCH=arm64
|
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
|
RUN apk add bash make git curl gcc musl-dev libc-dev binutils-gold inotify-tools vips-dev
|
||||||
|
|
||||||
COPY --from=generate /qsfera /qsfera
|
COPY --from=generate /qsfera /qsfera
|
||||||
|
|
||||||
WORKDIR /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
|
FROM alpine:3.23
|
||||||
ARG TARGETARCH=arm64
|
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 && \
|
RUN apk add --no-cache attr ca-certificates curl mailcap tree vips && \
|
||||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
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.description="КуСфера file-sync and share platform" \
|
||||||
org.opencontainers.image.licenses="Apache-2.0" \
|
org.opencontainers.image.licenses="Apache-2.0" \
|
||||||
org.opencontainers.image.documentation="" \
|
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"]
|
ENTRYPOINT ["/usr/bin/qsfera"]
|
||||||
CMD ["server"]
|
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.\
|
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.
|
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
|
# decomposeds3 specific settings
|
||||||
STORAGE_USERS_DECOMPOSEDS3_ENDPOINT: ${DECOMPOSEDS3_ENDPOINT:-http://minio:9000}
|
STORAGE_USERS_DECOMPOSEDS3_ENDPOINT: ${DECOMPOSEDS3_ENDPOINT:-http://minio:9000}
|
||||||
STORAGE_USERS_DECOMPOSEDS3_REGION: ${DECOMPOSEDS3_REGION:-default}
|
STORAGE_USERS_DECOMPOSEDS3_REGION: ${DECOMPOSEDS3_REGION:-default}
|
||||||
STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
|
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:-qsfera-secret-key}
|
STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting decomposeds3 in production}
|
||||||
STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:-qsfera-bucket}
|
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:
|
environment:
|
||||||
POSTGRES_DB: keycloak
|
POSTGRES_DB: keycloak
|
||||||
POSTGRES_USER: keycloak
|
POSTGRES_USER: keycloak
|
||||||
POSTGRES_PASSWORD: keycloak
|
POSTGRES_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production}
|
||||||
logging:
|
logging:
|
||||||
driver: ${LOG_DRIVER:-local}
|
driver: ${LOG_DRIVER:-local}
|
||||||
restart: always
|
restart: always
|
||||||
@@ -53,10 +53,10 @@ services:
|
|||||||
KC_DB: postgres
|
KC_DB: postgres
|
||||||
KC_DB_URL: "jdbc:postgresql://postgres:5432/keycloak"
|
KC_DB_URL: "jdbc:postgresql://postgres:5432/keycloak"
|
||||||
KC_DB_USERNAME: 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
|
KC_FEATURES: impersonation
|
||||||
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin}
|
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:?Set KEYCLOAK_ADMIN_USER before starting Keycloak in production}
|
||||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD before starting Keycloak in production}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.keycloak.entrypoints=https"
|
- "traefik.http.routers.keycloak.entrypoints=https"
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ services:
|
|||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"-c",
|
"-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:
|
volumes:
|
||||||
- minio-data:/data
|
- minio-data:/data
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
|
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting MinIO in production}
|
||||||
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key}
|
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting MinIO in production}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.minio.entrypoints=https"
|
- "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)
|
# 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}"
|
PROXY_ENABLE_BASIC_AUTH: "${PROXY_ENABLE_BASIC_AUTH:-false}"
|
||||||
# admin user password
|
# 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
|
# demo users
|
||||||
IDM_CREATE_DEMO_USERS: "${DEMO_USERS:-false}"
|
IDM_CREATE_DEMO_USERS: "${DEMO_USERS:-false}"
|
||||||
# idp login form settings
|
# 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)."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user