Compare commits

23 Commits

Author SHA1 Message Date
Курнат Андрей 78a13fe148 Update Android production verification to 1.3.10 2026-06-10 06:58:30 +03:00
Курнат Андрей 0acda06d5e Bump Android release to 1.3.10 2026-06-10 06:56:07 +03:00
Курнат Андрей 4f69012249 Document Android debug-signed rollout policy 2026-06-10 06:52:29 +03:00
Курнат Андрей 0961491d52 Add safe Windows desktop feed publication 2026-06-10 06:43:26 +03:00
Курнат Андрей 0ee974fa38 Add Windows desktop packaging script 2026-06-10 06:34:34 +03:00
Курнат Андрей 8f61d0ba8c Fix Windows tray show handling 2026-06-10 05:52:40 +03:00
Курнат Андрей d64957dfcd Publish Android 1.3.9 and repair Windows autostart 2026-06-10 05:39:50 +03:00
Курнат Андрей 8337bcb742 Clarify Android video mobile data setting 2026-06-10 05:32:19 +03:00
Курнат Андрей c1c72900e5 Publish Android 1.3.8 to Argus 2026-06-09 22:01:02 +03:00
Курнат Андрей 89f3cd0934 Add Android auto-upload mobile data limit 2026-06-09 21:54:25 +03:00
Курнат Андрей 4bf54f7fda Install Raspberry Pi production monitor timer 2026-06-09 21:26:17 +03:00
Курнат Андрей ccd20d76e7 Document Raspberry Pi metrics rollout 2026-06-09 21:17:27 +03:00
Курнат Андрей 9323f976d8 Add Raspberry Pi production verification runbook 2026-06-09 21:14:26 +03:00
Курнат Андрей 5e4dfc6642 Format desktop configfile test 2026-06-09 21:01:07 +03:00
Курнат Андрей fa47f9b32e Fix desktop image provider size fallback 2026-06-09 20:56:27 +03:00
Курнат Андрей ccc6bda044 Publish Android 1.3.7 to Argus 2026-06-08 21:56:31 +03:00
Курнат Андрей cf366b2699 Add Android backup roaming control 2026-06-08 21:50:33 +03:00
Курнат Андрей c9c4b1b17d Add production endpoint verifier 2026-06-08 21:40:09 +03:00
Курнат Андрей ac618d400b Polish client UI labels 2026-06-08 21:35:46 +03:00
Курнат Андрей 955be5134d Configure desktop update feed 2026-06-08 21:18:46 +03:00
Курнат Андрей e9aa9f8315 Pin Pi server stable image 2026-06-08 20:59:30 +03:00
Курнат Андрей 458831c83c Record Pi compose deployment 2026-06-08 20:11:16 +03:00
Курнат Андрей 43caef7a6a Prepare QSfera production release 2026-06-08 19:57:40 +03:00
82 changed files with 6127 additions and 264 deletions
+1
View File
@@ -0,0 +1 @@
artifacts/
+4
View File
@@ -12,6 +12,10 @@
# Local configuration files (sdk path, etc)
local.properties
# Local Argus release signing secrets
argus-signing.local.properties
signing/
# Mac .DS_Store files
.DS_Store
+23
View File
@@ -0,0 +1,23 @@
# 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`.
- 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 production-signed `1.3.10` / `38` release.
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, monotonically newer `versionCode`, Argus `androidVersionCode`, and signer digest before starting Android package installation.
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the production-signed `1.3.10` / `38` APK by the in-app updater because the signer digest check intentionally fails.
- For debug-signed `1.3.5` installs, the supported migration path is: back up unsynced local files, uninstall the debug-signed build, then install the current stable QSfera APK from Argus.
- On certificate mismatch, the Android app must show an explicit reinstall-required dialog instead of a generic update error.
## Release Checklist
1. Build the stable APK with the same release key material through `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
2. Verify the APK certificate before upload with `apksigner verify --print-certs`.
3. Publish to Argus only after certificate, package name, version code, and SHA-256 checks pass.
4. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
+28 -2
View File
@@ -14,6 +14,18 @@ def envValue(String primary, String legacy = null) {
return legacy ? System.getenv(legacy) : null
}
def releaseSigningEnv = [
[primary: 'QSFERA_RELEASE_KEYSTORE', legacy: 'OC_RELEASE_KEYSTORE'],
[primary: 'QSFERA_RELEASE_KEYSTORE_PASSWORD', legacy: 'OC_RELEASE_KEYSTORE_PASSWORD'],
[primary: 'QSFERA_RELEASE_KEY_ALIAS', legacy: 'OC_RELEASE_KEY_ALIAS'],
[primary: 'QSFERA_RELEASE_KEY_PASSWORD', legacy: 'OC_RELEASE_KEY_PASSWORD'],
]
def missingReleaseSigningEnvKeys = {
releaseSigningEnv.findAll { !envValue(it.primary, it.legacy) }
.collect { "${it.primary} (${it.legacy} legacy fallback)" }
}
dependencies {
// Data and domain modules
implementation project(':qsferaDomain')
@@ -123,8 +135,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 32
versionName = "1.3.4"
versionCode = 38
versionName = "1.3.10"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -235,6 +247,20 @@ android {
testNamespace "eu.qsfera.android.test"
}
gradle.taskGraph.whenReady { taskGraph ->
def releaseBuildRequested = taskGraph.allTasks.any { task ->
def taskName = task.name.toLowerCase()
taskName.endsWith("release") &&
(taskName.startsWith("assemble") || taskName.startsWith("bundle") || taskName.startsWith("package"))
}
def missingSigningEnvKeys = missingReleaseSigningEnvKeys()
if (releaseBuildRequested && !missingSigningEnvKeys.isEmpty()) {
throw new GradleException(
"Release builds must be signed. Missing env vars: ${missingSigningEnvKeys.join(', ')}"
)
}
}
// Updates output file names of a given variant to format
// [appName].[variant.versionName].[QSFERA_BUILD_NUMBER]-[variant.name].apk.
//
@@ -48,8 +48,12 @@ public abstract class PreferenceManager {
public static final String PREF__CAMERA_VIDEO_UPLOADS_ENABLED = "enable_video_uploads";
public static final String PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "picture_uploads_on_wifi";
public static final String PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY = "picture_uploads_on_charging";
public static final String PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING = "picture_uploads_allow_roaming";
public static final String PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT = "picture_uploads_mobile_data_limit";
public static final String PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "video_uploads_on_wifi";
public static final String PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY = "video_uploads_on_charging";
public static final String PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING = "video_uploads_allow_roaming";
public static final String PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT = "video_uploads_mobile_data_limit";
public static final String PREF__CAMERA_PICTURE_UPLOADS_PATH = "picture_uploads_path";
public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_uploads_path";
public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour";
@@ -41,10 +41,12 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ENABLED
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_PATH
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
@@ -68,6 +70,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
private var prefEnablePictureUploads: SwitchPreferenceCompat? = null
private var prefPictureUploadsPath: Preference? = null
private var prefPictureUploadsOnWifi: CheckBoxPreference? = null
private var prefPictureUploadsAllowRoaming: CheckBoxPreference? = null
private var prefPictureUploadsMobileDataLimit: ListPreference? = null
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
private var prefPictureUploadsSourcePath: Preference? = null
private var prefPictureUploadsClearSourcePaths: Preference? = null
@@ -100,6 +104,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefEnablePictureUploads = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ENABLED)
prefPictureUploadsPath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH)
prefPictureUploadsOnWifi = findPreference(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY)
prefPictureUploadsAllowRoaming = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ALLOW_ROAMING)
prefPictureUploadsMobileDataLimit = findPreference(PREF__CAMERA_PICTURE_UPLOADS_MOBILE_DATA_LIMIT)
prefPictureUploadsOnCharging = findPreference(PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY)
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
@@ -114,7 +120,9 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
prefPictureUploadsSourcePath?.title = String.format(prefPictureUploadsSourcePath?.title.toString(), comment)
prefPictureUploadsSourcePath?.let {
it.title = String.format(it.title?.toString().orEmpty(), comment)
}
initPreferenceListeners()
}
@@ -140,7 +148,7 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let {
if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName
} else {
@@ -157,9 +165,13 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
prefPictureUploadsAllowRoaming?.isChecked = it.allowRoaming
prefPictureUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefPictureUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefPictureUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
prefPictureUploadsBehaviour?.value = it.behavior.name
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
prefPictureUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
@@ -241,26 +253,40 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
prefPictureUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
picturesViewModel.useWifiOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.useWifiOnly(value)
prefPictureUploadsAllowRoaming?.isEnabled = !value
prefPictureUploadsMobileDataLimit?.isEnabled = !value
value
}
prefPictureUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.allowRoaming(value)
value
}
prefPictureUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue ->
val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false
picturesViewModel.setMobileDataLimitBytes(value)
true
}
prefPictureUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
picturesViewModel.useChargingOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
picturesViewModel.useChargingOnly(value)
value
}
prefPictureUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
picturesViewModel.handleSelectAccount(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
picturesViewModel.handleSelectAccount(value)
true
}
prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
picturesViewModel.handleSelectBehaviour(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
picturesViewModel.handleSelectBehaviour(value)
true
}
}
@@ -277,6 +303,8 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
prefPictureUploadsPath?.isEnabled = value
prefPictureUploadsOnWifi?.isEnabled = value
prefPictureUploadsAllowRoaming?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
prefPictureUploadsMobileDataLimit?.isEnabled = value && prefPictureUploadsOnWifi?.isChecked != true
prefPictureUploadsOnCharging?.isEnabled = value
prefPictureUploadsSourcePath?.isEnabled = value
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
@@ -291,9 +319,11 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
prefPictureUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
prefPictureUploadsClearSourcePaths?.isEnabled = false
prefPictureUploadsOnWifi?.isChecked = false
prefPictureUploadsAllowRoaming?.isChecked = false
prefPictureUploadsMobileDataLimit?.value = "0"
prefPictureUploadsOnCharging?.isChecked = false
prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name
prefPictureUploadsLastSync?.summary = null
prefPictureUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
@@ -305,6 +335,13 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
}
}
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
if (lastSyncTimestamp <= 0L) {
getString(R.string.prefs_camera_upload_last_sync_never)
} else {
DisplayUtils.unixTimeToHumanReadable(lastSyncTimestamp)
}
companion object {
private const val PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS = "picture_uploads_clear_source_paths"
}
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
@@ -111,6 +112,26 @@ class SettingsPictureUploadsViewModel(
}
}
fun allowRoaming(allowRoaming: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(allowRoaming = allowRoaming)
)
)
}
}
fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes)
)
)
}
}
fun useChargingOnly(chargingOnly: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
savePictureUploadsConfigurationUseCase(
@@ -181,7 +202,7 @@ class SettingsPictureUploadsViewModel(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
)
)
)
@@ -210,6 +231,10 @@ class SettingsPictureUploadsViewModel(
accountName: String? = _pictureUploads.value?.accountName,
uploadPath: String? = _pictureUploads.value?.uploadPath,
wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly,
allowRoaming: Boolean? = _pictureUploads.value?.allowRoaming,
mobileDataLimitBytes: Long? = _pictureUploads.value?.mobileDataLimitBytes,
mobileDataPeriodStartTimestamp: Long? = _pictureUploads.value?.mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod: Long? = _pictureUploads.value?.mobileDataBytesQueuedInPeriod,
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
sourcePath: String? = _pictureUploads.value?.sourcePath,
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
@@ -220,9 +245,13 @@ class SettingsPictureUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
wifiOnly = wifiOnly != false,
allowRoaming = allowRoaming == true,
mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _pictureUploads.value?.name ?: pictureUploadsName,
spaceId = spaceId,
).also {
@@ -238,7 +267,7 @@ class SettingsPictureUploadsViewModel(
fun getUploadPathString(): String {
val spaceName = handleSpaceName(pictureUploadsSpace?.name)
val spaceName = handleSpaceName(pictureUploadsSpace?.name).orEmpty()
val uploadPath = pictureUploads.value?.uploadPath
val spaceId = pictureUploads.value?.spaceId
@@ -42,9 +42,11 @@ import androidx.preference.SwitchPreferenceCompat
import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_ENABLED
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_PATH
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_SOURCE
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY
@@ -68,6 +70,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
private var prefEnableVideoUploads: SwitchPreferenceCompat? = null
private var prefVideoUploadsPath: Preference? = null
private var prefVideoUploadsOnWifi: CheckBoxPreference? = null
private var prefVideoUploadsAllowRoaming: CheckBoxPreference? = null
private var prefVideoUploadsMobileDataLimit: ListPreference? = null
private var prefVideoUploadsOnCharging: CheckBoxPreference? = null
private var prefVideoUploadsSourcePath: Preference? = null
private var prefVideoUploadsClearSourcePaths: Preference? = null
@@ -100,6 +104,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefEnableVideoUploads = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ENABLED)
prefVideoUploadsPath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH)
prefVideoUploadsOnWifi = findPreference(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY)
prefVideoUploadsAllowRoaming = findPreference(PREF__CAMERA_VIDEO_UPLOADS_ALLOW_ROAMING)
prefVideoUploadsMobileDataLimit = findPreference(PREF__CAMERA_VIDEO_UPLOADS_MOBILE_DATA_LIMIT)
prefVideoUploadsOnCharging = findPreference(PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY)
prefVideoUploadsSourcePath = findPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE)
prefVideoUploadsClearSourcePaths = findPreference(PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS)
@@ -112,7 +118,9 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsAccount = findPreference<ListPreference>(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME)
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
prefVideoUploadsSourcePath?.title = String.format(prefVideoUploadsSourcePath?.title.toString(), comment)
prefVideoUploadsSourcePath?.let {
it.title = String.format(it.title?.toString().orEmpty(), comment)
}
initPreferenceListeners()
}
@@ -138,7 +146,7 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
showMessageInSnackbar(getString(R.string.prefs_automatic_uploads_not_available_users_light))
} else {
val currentAccount = manageAccountsViewModel.getCurrentAccount()?.name
currentAccount?.let {
if (currentAccount != null) {
selectedAccount = if (manageAccountsViewModel.checkUserLight(currentAccount)) {
availableAccounts.first().accountName
} else {
@@ -155,9 +163,13 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsSourcePath?.summary = getSourcePathsSummary(sourcePaths)
prefVideoUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
prefVideoUploadsOnWifi?.isChecked = it.wifiOnly
prefVideoUploadsAllowRoaming?.isChecked = it.allowRoaming
prefVideoUploadsAllowRoaming?.isEnabled = !it.wifiOnly
prefVideoUploadsMobileDataLimit?.value = it.mobileDataLimitBytes.toString()
prefVideoUploadsMobileDataLimit?.isEnabled = !it.wifiOnly
prefVideoUploadsOnCharging?.isChecked = it.chargingOnly
prefVideoUploadsBehaviour?.value = it.behavior.name
prefVideoUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
prefVideoUploadsLastSync?.summary = formatLastSyncSummary(it.lastSyncTimestamp)
spaceId = it.spaceId
} ?: resetFields()
}
@@ -239,26 +251,40 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
prefVideoUploadsOnWifi?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
videosViewModel.useWifiOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.useWifiOnly(value)
prefVideoUploadsAllowRoaming?.isEnabled = !value
prefVideoUploadsMobileDataLimit?.isEnabled = !value
value
}
prefVideoUploadsAllowRoaming?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.allowRoaming(value)
value
}
prefVideoUploadsMobileDataLimit?.setOnPreferenceChangeListener { _, newValue ->
val value = (newValue as? String)?.toLongOrNull() ?: return@setOnPreferenceChangeListener false
videosViewModel.setMobileDataLimitBytes(value)
true
}
prefVideoUploadsOnCharging?.setOnPreferenceChangeListener { _, newValue ->
newValue as Boolean
videosViewModel.useChargingOnly(newValue)
newValue
val value = newValue as? Boolean ?: return@setOnPreferenceChangeListener false
videosViewModel.useChargingOnly(value)
value
}
prefVideoUploadsAccount?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
videosViewModel.handleSelectAccount(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
videosViewModel.handleSelectAccount(value)
true
}
prefVideoUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
newValue as String
videosViewModel.handleSelectBehaviour(newValue)
val value = newValue as? String ?: return@setOnPreferenceChangeListener false
videosViewModel.handleSelectBehaviour(value)
true
}
}
@@ -275,6 +301,8 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
prefVideoUploadsPath?.isEnabled = value
prefVideoUploadsOnWifi?.isEnabled = value
prefVideoUploadsAllowRoaming?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
prefVideoUploadsMobileDataLimit?.isEnabled = value && prefVideoUploadsOnWifi?.isChecked != true
prefVideoUploadsOnCharging?.isEnabled = value
prefVideoUploadsSourcePath?.isEnabled = value
prefVideoUploadsClearSourcePaths?.isEnabled = value && videosViewModel.getVideoUploadsSourcePaths().isNotEmpty()
@@ -289,9 +317,11 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
prefVideoUploadsSourcePath?.summary = getString(R.string.prefs_camera_upload_source_paths_empty)
prefVideoUploadsClearSourcePaths?.isEnabled = false
prefVideoUploadsOnWifi?.isChecked = false
prefVideoUploadsAllowRoaming?.isChecked = false
prefVideoUploadsMobileDataLimit?.value = "0"
prefVideoUploadsOnCharging?.isChecked = false
prefVideoUploadsBehaviour?.value = UploadBehavior.COPY.name
prefVideoUploadsLastSync?.summary = null
prefVideoUploadsLastSync?.summary = getString(R.string.prefs_camera_upload_last_sync_never)
}
private fun getSourcePathsSummary(sourcePaths: List<String>): String =
@@ -303,6 +333,13 @@ class SettingsVideoUploadsFragment : PreferenceFragmentCompat() {
}
}
private fun formatLastSyncSummary(lastSyncTimestamp: Long): String =
if (lastSyncTimestamp <= 0L) {
getString(R.string.prefs_camera_upload_last_sync_never)
} else {
DisplayUtils.unixTimeToHumanReadable(lastSyncTimestamp)
}
companion object {
private const val PREF_VIDEO_UPLOADS_CLEAR_SOURCE_PATHS = "video_uploads_clear_source_paths"
}
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
@@ -111,6 +112,26 @@ class SettingsVideoUploadsViewModel(
}
}
fun allowRoaming(allowRoaming: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(allowRoaming = allowRoaming)
)
)
}
}
fun setMobileDataLimitBytes(mobileDataLimitBytes: Long) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(mobileDataLimitBytes = mobileDataLimitBytes)
)
)
}
}
fun useChargingOnly(chargingOnly: Boolean) {
viewModelScope.launch(coroutinesDispatcherProvider.io) {
saveVideoUploadsConfigurationUseCase(
@@ -181,7 +202,7 @@ class SettingsVideoUploadsViewModel(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
)
)
)
@@ -210,6 +231,10 @@ class SettingsVideoUploadsViewModel(
accountName: String? = _videoUploads.value?.accountName,
uploadPath: String? = _videoUploads.value?.uploadPath,
wifiOnly: Boolean? = _videoUploads.value?.wifiOnly,
allowRoaming: Boolean? = _videoUploads.value?.allowRoaming,
mobileDataLimitBytes: Long? = _videoUploads.value?.mobileDataLimitBytes,
mobileDataPeriodStartTimestamp: Long? = _videoUploads.value?.mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod: Long? = _videoUploads.value?.mobileDataBytesQueuedInPeriod,
chargingOnly: Boolean? = _videoUploads.value?.chargingOnly,
sourcePath: String? = _videoUploads.value?.sourcePath,
behavior: UploadBehavior? = _videoUploads.value?.behavior,
@@ -221,9 +246,13 @@ class SettingsVideoUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
wifiOnly = wifiOnly != false,
allowRoaming = allowRoaming == true,
mobileDataLimitBytes = mobileDataLimitBytes ?: 0,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp ?: 0,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod ?: 0,
chargingOnly = chargingOnly == true,
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _videoUploads.value?.name ?: videoUploadsName,
spaceId = spaceId,
).also {
@@ -239,7 +268,7 @@ class SettingsVideoUploadsViewModel(
fun getUploadPathString(): String {
val spaceName = handleSpaceName(videoUploadsSpace?.name)
val spaceName = handleSpaceName(videoUploadsSpace?.name).orEmpty()
val uploadPath = videoUploads.value?.uploadPath
val spaceId = videoUploads.value?.spaceId
@@ -123,6 +123,15 @@ class AppUpdateManager(private val fragment: Fragment) {
}
private fun showErrorDialog(context: Context, error: Throwable) {
if (error is SignatureMismatchException) {
AlertDialog.Builder(context)
.setTitle(R.string.update_reinstall_required_title)
.setMessage(R.string.update_reinstall_required_message)
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
AlertDialog.Builder(context)
.setTitle(R.string.common_error)
.setMessage(context.getString(R.string.update_error, error.message ?: error.javaClass.simpleName))
@@ -216,7 +225,7 @@ class AppUpdateManager(private val fragment: Fragment) {
}
if (!hasTrustedSignature(context, downloadedPackageInfo)) {
error(context.getString(R.string.update_signature_error))
throw SignatureMismatchException(context.getString(R.string.update_signature_error))
}
}
@@ -324,6 +333,8 @@ class AppUpdateManager(private val fragment: Fragment) {
val sha256: String
)
private class SignatureMismatchException(message: String) : IllegalStateException(message)
companion object {
private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz"
private const val ARGUS_MANIFEST_URL =
@@ -42,6 +42,9 @@ import eu.qsfera.android.workers.OldLogsCollectorWorker
import eu.qsfera.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
@@ -90,28 +93,30 @@ class WorkManagerProvider(
* concurrent scans or redundant enqueues on rapid foreground/background switches.
*/
fun enqueueImmediateAutomaticUploadsWorker() {
val wm = WorkManager.getInstance(context)
CoroutineScope(Dispatchers.IO).launch {
val wm = WorkManager.getInstance(context)
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return@launch
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
fun enqueueOldLogsCollectorWorker() {
@@ -58,7 +58,7 @@ class UploadFileFromContentUriUseCase(
.putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString())
.build()
val networkRequired = if (params.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
val networkRequired = getRequiredNetworkType(params.wifiOnly, params.allowRoaming)
val constraints = Constraints.Builder()
.setRequiredNetworkType(networkRequired)
.setRequiresCharging(params.chargingOnly)
@@ -109,6 +109,16 @@ class UploadFileFromContentUriUseCase(
val uploadPath: String,
val uploadIdInStorageManager: Long,
val wifiOnly: Boolean,
val allowRoaming: Boolean = true,
val chargingOnly: Boolean,
)
companion object {
fun getRequiredNetworkType(wifiOnly: Boolean, allowRoaming: Boolean): NetworkType =
when {
wifiOnly -> NetworkType.UNMETERED
allowRoaming -> NetworkType.CONNECTED
else -> NetworkType.NOT_ROAMING
}
}
}
@@ -22,6 +22,7 @@
package eu.qsfera.android.workers
import android.content.Context
import android.net.ConnectivityManager
import android.net.Uri
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
@@ -51,7 +52,9 @@ import org.koin.core.component.inject
import timber.log.Timber
import java.io.File
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
import java.util.concurrent.TimeUnit
class AutomaticUploadsWorker(
@@ -128,8 +131,13 @@ class AutomaticUploadsWorker(
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
sourcePaths.forEach { sourcePath ->
val sourceUri: Uri = sourcePath.toUri()
DocumentFile.fromTreeUri(applicationContext, sourceUri)
val hasPersistedReadPermission = appContext.contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
?: throw IllegalArgumentException("Source path is not a valid tree URI: $sourcePath")
if (!hasPersistedReadPermission || !documentTree.canRead()) {
throw IllegalArgumentException("Source path is not readable: $sourcePath")
}
}
}
@@ -137,9 +145,12 @@ class AutomaticUploadsWorker(
WorkManager.getInstance(appContext).cancelUniqueWork(AUTOMATIC_UPLOADS_WORKER)
}
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration?) {
if (folderBackUpConfiguration == null) return
private fun isActiveNetworkMetered(): Boolean {
val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
return connectivityManager?.isActiveNetworkMetered != false
}
private fun syncFolder(folderBackUpConfiguration: FolderBackUpConfiguration) {
val syncType = when {
folderBackUpConfiguration.isPictureUploads -> SyncType.PICTURE_UPLOADS
folderBackUpConfiguration.isVideoUploads -> SyncType.VIDEO_UPLOADS
@@ -148,6 +159,9 @@ class AutomaticUploadsWorker(
}
val currentTimestamp = System.currentTimeMillis()
val activeNetworkMetered = isActiveNetworkMetered()
var mobileDataQuotaState = resolveMobileDataQuotaState(folderBackUpConfiguration, currentTimestamp)
var mobileDataLimitReached = false
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
getFilesReadyToUpload(
@@ -158,19 +172,34 @@ class AutomaticUploadsWorker(
)
}
showNotification(syncType, localPicturesDocumentFiles.size)
var queuedUploadsCount = 0
for (documentFile in localPicturesDocumentFiles) {
// Dedup: if this content URI already has a queued, in-progress, or succeeded transfer,
// skip it. Without this, a worker killed mid-loop (before updateTimestamp) or a
// file whose lastModified changed (e.g. media scanner) would be re-discovered and
// enqueued with a new upload ID leading to duplicate uploads or 0-byte files
// enqueued with a new upload ID - leading to duplicate uploads or 0-byte files
// when two workers race on the same cache path.
val contentUri = documentFile.uri.toString()
if (transferRepository.existsNonFailedTransferForUri(contentUri)) {
Timber.d("Skipping already-tracked file: %s", documentFile.name)
continue
}
val documentFileSize = documentFile.length()
if (!canQueueMobileDataUpload(
wifiOnly = folderBackUpConfiguration.wifiOnly,
isActiveNetworkMetered = activeNetworkMetered,
mobileDataLimitBytes = folderBackUpConfiguration.mobileDataLimitBytes,
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod,
fileSize = documentFileSize
)
) {
mobileDataLimitReached = true
Timber.i(
"Skipping automatic upload %s because the daily mobile data queue limit has been reached",
documentFile.name
)
continue
}
val uploadId = storeInUploadsDatabase(
documentFile = documentFile,
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
@@ -190,13 +219,29 @@ class AutomaticUploadsWorker(
accountName = folderBackUpConfiguration.accountName,
uploadId = uploadId,
wifiOnly = folderBackUpConfiguration.wifiOnly,
allowRoaming = folderBackUpConfiguration.allowRoaming,
chargingOnly = folderBackUpConfiguration.chargingOnly
)
if (shouldCountAgainstMobileDataLimit(
wifiOnly = folderBackUpConfiguration.wifiOnly,
isActiveNetworkMetered = activeNetworkMetered,
mobileDataLimitBytes = folderBackUpConfiguration.mobileDataLimitBytes,
)
) {
mobileDataQuotaState = mobileDataQuotaState.copy(
bytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod + documentFileSize
)
}
queuedUploadsCount += 1
}
showNotification(syncType, queuedUploadsCount)
// Save safeTimestamp (not currentTimestamp) so that files skipped by the
// write-safety buffer are re-evaluated on the next run instead of being lost.
// Keep the previous timestamp when the mobile-data daily limit stops the scan,
// so skipped files are re-evaluated after the quota period resets.
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
updateTimestamp(folderBackUpConfiguration, syncType, safeTimestamp)
val nextTimestamp = if (mobileDataLimitReached) folderBackUpConfiguration.lastSyncTimestamp else safeTimestamp
updateConfigurationAfterScan(folderBackUpConfiguration, syncType, nextTimestamp, mobileDataQuotaState)
}
private fun showNotification(
@@ -245,23 +290,29 @@ class AutomaticUploadsWorker(
)
}
private fun updateTimestamp(
private fun updateConfigurationAfterScan(
folderBackUpConfiguration: FolderBackUpConfiguration,
syncType: SyncType,
currentTimestamp: Long,
nextSyncTimestamp: Long,
mobileDataQuotaState: MobileDataQuotaState,
) {
val updatedConfiguration = folderBackUpConfiguration.copy(
lastSyncTimestamp = nextSyncTimestamp,
mobileDataPeriodStartTimestamp = mobileDataQuotaState.periodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataQuotaState.bytesQueuedInPeriod
)
when (syncType) {
SyncType.PICTURE_UPLOADS -> {
val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase by inject()
savePictureUploadsConfigurationUseCase(
SavePictureUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp))
SavePictureUploadsConfigurationUseCase.Params(updatedConfiguration)
)
}
SyncType.VIDEO_UPLOADS -> {
val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase by inject()
saveVideoUploadsConfigurationUseCase(
SaveVideoUploadsConfigurationUseCase.Params(folderBackUpConfiguration.copy(lastSyncTimestamp = currentTimestamp))
SaveVideoUploadsConfigurationUseCase.Params(updatedConfiguration)
)
}
}
@@ -275,7 +326,7 @@ class AutomaticUploadsWorker(
): List<DocumentFile> {
val sourceUri: Uri = sourcePath.toUri()
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
val arrayOfLocalFiles = documentTree?.listFiles() ?: arrayOf()
val arrayOfLocalFiles = documentTree?.listFiles().orEmpty()
// Exclude files modified within the last few seconds. Camera apps may still be
// writing the file (not all apps use atomic rename), so picking it up too early
@@ -290,7 +341,7 @@ class AutomaticUploadsWorker(
Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path}")
Timber.i("${arrayOfLocalFiles.size} files found in folder: ${sourceUri.path.orEmpty()}")
Timber.i("${filteredList.size} files are ${syncType.name} and were taken after last sync")
return filteredList
@@ -304,6 +355,7 @@ class AutomaticUploadsWorker(
accountName: String,
uploadId: Long,
wifiOnly: Boolean,
allowRoaming: Boolean,
chargingOnly: Boolean
) {
val lastModifiedInSeconds = (lastModified / 1000L).toString()
@@ -317,6 +369,7 @@ class AutomaticUploadsWorker(
uploadPath = uploadPath,
uploadIdInStorageManager = uploadId,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
chargingOnly = chargingOnly
)
)
@@ -347,6 +400,11 @@ class AutomaticUploadsWorker(
}
companion object {
data class MobileDataQuotaState(
val periodStartTimestamp: Long,
val bytesQueuedInPeriod: Long,
)
const val AUTOMATIC_UPLOADS_WORKER = "AUTOMATIC_UPLOADS_WORKER"
const val IMMEDIATE_UPLOADS_WORKER = "IMMEDIATE_AUTOMATIC_UPLOADS_WORKER"
const val MEDIA_STORE_UPLOADS_WORKER = "MEDIA_STORE_AUTOMATIC_UPLOADS_WORKER"
@@ -357,5 +415,49 @@ class AutomaticUploadsWorker(
private const val videoUploadsNotificationId = 102
const val WRITE_SAFETY_BUFFER_MS = 10_000L
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L
fun resolveMobileDataQuotaState(
folderBackUpConfiguration: FolderBackUpConfiguration,
currentTimestamp: Long,
timeZone: TimeZone = TimeZone.getDefault(),
): MobileDataQuotaState {
val currentPeriodStart = getMobileDataLimitPeriodStart(currentTimestamp, timeZone)
val bytesQueuedInPeriod =
if (folderBackUpConfiguration.mobileDataPeriodStartTimestamp == currentPeriodStart) {
folderBackUpConfiguration.mobileDataBytesQueuedInPeriod
} else {
0L
}
return MobileDataQuotaState(
periodStartTimestamp = currentPeriodStart,
bytesQueuedInPeriod = bytesQueuedInPeriod,
)
}
fun getMobileDataLimitPeriodStart(timestamp: Long, timeZone: TimeZone = TimeZone.getDefault()): Long =
Calendar.getInstance(timeZone).apply {
timeInMillis = timestamp
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
fun shouldCountAgainstMobileDataLimit(
wifiOnly: Boolean,
isActiveNetworkMetered: Boolean,
mobileDataLimitBytes: Long,
): Boolean =
!wifiOnly && isActiveNetworkMetered && mobileDataLimitBytes > 0
fun canQueueMobileDataUpload(
wifiOnly: Boolean,
isActiveNetworkMetered: Boolean,
mobileDataLimitBytes: Long,
mobileDataBytesQueuedInPeriod: Long,
fileSize: Long,
): Boolean =
!shouldCountAgainstMobileDataLimit(wifiOnly, isActiveNetworkMetered, mobileDataLimitBytes) ||
fileSize <= mobileDataLimitBytes - mobileDataBytesQueuedInPeriod
}
}
@@ -60,23 +60,23 @@
<string name="prefs_touches_with_other_visible_windows_summary">Разрешает взаимодействие, когда есть перекрытие другим видимым или прозрачным окном. Это нужно, если вы используете приложения для фильтрации света от экрана. Перекрывающее приложение сможет получать информацию о взаимодействии.</string>
<string name="confirmation_touches_with_other_windows_title">Вы уверены, что хотите включить эту функцию?</string>
<string name="confirmation_touches_with_other_windows_message">Используйте эту функцию на свой страх и риск. Вредоносное приложение может попытаться обманом заставить вас выполнить какие-либо действия, используя другие представления.</string>
<string name="prefs_subsection_picture_uploads">Автоматическая загрузка изображений</string>
<string name="prefs_subsection_picture_uploads_summary">Управляйте местоположением и поведением автоматически загружаемых фотографий</string>
<string name="prefs_subsection_video_uploads">Автоматическая загрузка видео</string>
<string name="prefs_subsection_video_uploads_summary">Настройка размещения и поведения видеороликов, загружаемых автоматически</string>
<string name="prefs_camera_picture_upload">Загрузка изображений</string>
<string name="prefs_camera_picture_upload_summary">Автоматически загружать изображения, сделанные камерой</string>
<string name="prefs_camera_picture_upload_path_title">Путь загрузки изображений</string>
<string name="prefs_camera_picture_upload_conditions_title">Условия для загрузки</string>
<string name="prefs_camera_picture_upload_conditions_summary">Автозагрузка будет запущена только при удовлетворении выбранных ниже условий</string>
<string name="prefs_camera_picture_upload_on_wifi">Загрузка изображений только через wifi</string>
<string name="prefs_camera_picture_upload_on_charging">Загрузка изображений только во время зарядки</string>
<string name="prefs_subsection_picture_uploads">Резервное копирование фото</string>
<string name="prefs_subsection_picture_uploads_summary">Выберите папки телефона и условия загрузки</string>
<string name="prefs_subsection_video_uploads">Резервное копирование видео</string>
<string name="prefs_subsection_video_uploads_summary">Выберите папки с видео и условия загрузки</string>
<string name="prefs_camera_picture_upload">Копировать фото</string>
<string name="prefs_camera_picture_upload_summary">Автоматически копировать фото из выбранных папок телефона</string>
<string name="prefs_camera_picture_upload_path_title">Папка назначения в КуСфера</string>
<string name="prefs_camera_picture_upload_conditions_title">Условия загрузки</string>
<string name="prefs_camera_picture_upload_conditions_summary">Копирование запускается только при выполнении выбранных условий</string>
<string name="prefs_camera_picture_upload_on_wifi">Копировать фото только по Wi-Fi</string>
<string name="prefs_camera_picture_upload_on_charging">Копировать фото только во время зарядки</string>
<string name="prefs_camera_upload_on_charging_summary">Чтобы включить этот параметр, сначала включите загрузку только по Wi-Fi</string>
<string name="prefs_camera_video_upload">Загрузка видео</string>
<string name="prefs_camera_video_upload_summary">Автоматически загружать видео, записанные камерой</string>
<string name="prefs_camera_video_upload_path_title">Путь загрузки видео</string>
<string name="prefs_camera_video_upload_on_wifi">Загрузка видео только через wifi</string>
<string name="prefs_camera_video_upload_on_charging">Загрузка видео только во время зарядки</string>
<string name="prefs_camera_video_upload">Копировать видео</string>
<string name="prefs_camera_video_upload_summary">Автоматически копировать видео из выбранных папок телефона</string>
<string name="prefs_camera_video_upload_path_title">Папка назначения в КуСфера</string>
<string name="prefs_camera_video_upload_on_wifi">Копировать видео только по Wi-Fi</string>
<string name="prefs_camera_video_upload_on_charging">Копировать видео только во время зарядки</string>
<string name="prefs_automatic_uploads_not_available_users_light">Автоматические загрузки недоступны для пользователей Light</string>
<string name="prefs_subsection_advanced">Дополнительно</string>
<string name="prefs_subsection_notifications">Управление уведомлениями</string>
@@ -123,6 +123,8 @@
<string name="update_version_error">Скачанное обновление не новее установленного приложения.</string>
<string name="update_metadata_error">Скачанное обновление не совпадает с метаданными релиза в Аргус.</string>
<string name="update_signature_error">Скачанное обновление подписано не сертификатом установленного приложения.</string>
<string name="update_reinstall_required_title">Требуется переустановка</string>
<string name="update_reinstall_required_message">Эта установленная сборка подписана другим сертификатом, чем текущий релиз QSfera в Аргус. Android не может обновить её поверх установленной. Сохраните несинхронизированные файлы, удалите эту сборку и установите QSfera из Аргус.</string>
<string name="update_apk_parse_error">Скачанное обновление не удалось прочитать как Android-пакет.</string>
<string name="update_release_not_found">Последний Android-релиз не найден.</string>
<string name="pattern_enter_pattern">Пожалуйста, введите графический ключ</string>
@@ -553,17 +555,20 @@
<string name="file_list__footer__files">%1$d файлов</string>
<string name="file_list__footer__files_and_folder">%1$d файлов, 1 каталог</string>
<string name="file_list__footer__files_and_folders">%1$d файлов, %2$d каталогов</string>
<string name="prefs_picture_upload_account">Учётная запись для закачки изображений</string>
<string name="prefs_video_upload_account">Учётная запись для закачки видео</string>
<string name="prefs_camera_upload_source_path_title">Каталог камеры (%1$s)</string>
<string name="prefs_picture_upload_account">Учётная запись для фото</string>
<string name="prefs_video_upload_account">Учётная запись для видео</string>
<string name="prefs_camera_upload_source_path_title">Папки телефона (%1$s)</string>
<string name="prefs_camera_upload_source_path_title_required">обязательно</string>
<string name="prefs_camera_upload_source_paths_clear_title">Очистить выбранные папки</string>
<string name="prefs_camera_upload_source_paths_empty">Папки не выбраны</string>
<string name="confirmation_clear_camera_upload_sources_title">Очистить выбранные папки</string>
<string name="confirmation_clear_camera_upload_sources_message">Автозагрузка перестанет проверять папки телефона, пока вы снова не добавите папку.</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл будет</string>
<string name="prefs_camera_upload_behaviour_title">Исходный файл будет</string>
<string name="prefs_camera_upload_last_sync_title">Последняя синхронизация</string>
<string name="prefs_camera_upload_source_paths_clear_title">Убрать выбранные папки</string>
<string name="prefs_camera_upload_source_paths_empty">Папки телефона не выбраны</string>
<string name="confirmation_clear_camera_upload_sources_title">Убрать выбранные папки</string>
<string name="confirmation_clear_camera_upload_sources_message">Копирование перестанет проверять папки телефона, пока вы снова не добавите папку.</string>
<string name="prefs_camera_upload_allow_roaming">Разрешить копирование в роуминге</string>
<string name="prefs_camera_upload_allow_roaming_summary">Применяется только если копирование только по Wi-Fi выключено</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл после копирования</string>
<string name="prefs_camera_upload_behaviour_title">Исходный файл после копирования</string>
<string name="prefs_camera_upload_last_sync_title">Последняя проверка</string>
<string name="prefs_camera_upload_last_sync_never">Ожидает первой проверки</string>
<string name="notifications_permission_denied">Вы можете обновить свои установки в Настройки</string>
<string name="upload_copy_files">Скопировать файл</string>
<string name="upload_move_files">Переместить файл</string>
@@ -61,23 +61,24 @@
<string name="prefs_touches_with_other_visible_windows_summary">Allow touches when the view is obscured by another visible window. Enable it to use light filtering apps.</string>
<string name="confirmation_touches_with_other_windows_title">Are you sure you want to enable this feature?</string>
<string name="confirmation_touches_with_other_windows_message">Use this feature at your own risk. A malicious application could try to spoof you into unknowingly performing some actions, using other views.</string>
<string name="prefs_subsection_picture_uploads">Automatic picture uploads</string>
<string name="prefs_subsection_picture_uploads_summary">Manage location and behavior of the pictures uploaded automatically</string>
<string name="prefs_subsection_video_uploads">Automatic video uploads</string>
<string name="prefs_subsection_video_uploads_summary">Manage location and behavior of the videos uploaded automatically</string>
<string name="prefs_camera_picture_upload">Picture uploads</string>
<string name="prefs_camera_picture_upload_summary">Automatically upload pictures taken by camera</string>
<string name="prefs_camera_picture_upload_path_title">Picture upload path</string>
<string name="prefs_camera_picture_upload_conditions_title">Conditions to execute uploads</string>
<string name="prefs_camera_picture_upload_conditions_summary">Uploads will be triggered when all selected options are fulfilled</string>
<string name="prefs_camera_picture_upload_on_wifi">Upload pictures via wifi only</string>
<string name="prefs_camera_picture_upload_on_charging">Upload pictures only when charging</string>
<string name="prefs_subsection_picture_uploads">Photo backup</string>
<string name="prefs_subsection_picture_uploads_summary">Choose which phone folders are backed up and when uploads run</string>
<string name="prefs_subsection_video_uploads">Video backup</string>
<string name="prefs_subsection_video_uploads_summary">Choose which video folders are backed up and when uploads run</string>
<string name="prefs_camera_picture_upload">Back up photos</string>
<string name="prefs_camera_picture_upload_summary">Automatically back up photos from selected phone folders</string>
<string name="prefs_camera_picture_upload_path_title">QSfera destination folder</string>
<string name="prefs_camera_picture_upload_conditions_title">Upload conditions</string>
<string name="prefs_camera_picture_upload_conditions_summary">Backup runs only when the selected conditions are met</string>
<string name="prefs_camera_picture_upload_on_wifi">Use Wi-Fi only for photo backup</string>
<string name="prefs_camera_picture_upload_on_charging">Back up photos only while charging</string>
<string name="prefs_camera_upload_on_charging_summary">Enable wifi only uploads to activate this option</string>
<string name="prefs_camera_video_upload">Video uploads</string>
<string name="prefs_camera_video_upload_summary">Automatically upload videos recorded by camera</string>
<string name="prefs_camera_video_upload_path_title">Video upload path</string>
<string name="prefs_camera_video_upload_on_wifi">Upload videos via wifi only</string>
<string name="prefs_camera_video_upload_on_charging">Upload videos only when charging</string>
<string name="prefs_camera_video_upload">Back up videos</string>
<string name="prefs_camera_video_upload_summary">Automatically back up videos from selected phone folders</string>
<string name="prefs_camera_video_upload_path_title">QSfera destination folder</string>
<string name="prefs_camera_video_upload_on_wifi">Never use mobile data for videos</string>
<string name="prefs_camera_video_upload_on_wifi_summary">Recommended for large videos. Turn off to allow metered-network limits and roaming controls.</string>
<string name="prefs_camera_video_upload_on_charging">Back up videos only while charging</string>
<string name="prefs_automatic_uploads_not_available_users_light">Automatic uploads are not available for users light</string>
<string name="prefs_subsection_advanced">Advanced</string>
<string name="prefs_subsection_notifications">Manage notifications</string>
@@ -124,6 +125,8 @@
<string name="update_version_error">Downloaded update is not newer than the installed app.</string>
<string name="update_metadata_error">Downloaded update does not match Argus release metadata.</string>
<string name="update_signature_error">Downloaded update is not signed with the installed app certificate.</string>
<string name="update_reinstall_required_title">Reinstall required</string>
<string name="update_reinstall_required_message">This installed build was signed with a different certificate than the current QSfera release in Argus. Android cannot update it in place. Back up unsynced files, uninstall this build, then install QSfera from Argus.</string>
<string name="update_apk_parse_error">Downloaded update could not be read as an Android package.</string>
<string name="update_release_not_found">Latest Android release was not found.</string>
<string name="pattern_enter_pattern">Please enter your pattern</string>
@@ -591,17 +594,36 @@
<string name="file_list__footer__files">%1$d files</string>
<string name="file_list__footer__files_and_folder">%1$d files, 1 folder</string>
<string name="file_list__footer__files_and_folders">%1$d files, %2$d folders</string>
<string name="prefs_picture_upload_account">Account to upload pictures</string>
<string name="prefs_video_upload_account">Account to upload videos</string>
<string name="prefs_camera_upload_source_path_title">Camera folder (%1$s)</string>
<string name="prefs_picture_upload_account">QSfera account for photos</string>
<string name="prefs_video_upload_account">QSfera account for videos</string>
<string name="prefs_camera_upload_source_path_title">Phone folders (%1$s)</string>
<string name="prefs_camera_upload_source_path_title_required">required</string>
<string name="prefs_camera_upload_source_paths_clear_title">Clear selected folders</string>
<string name="prefs_camera_upload_source_paths_empty">No folders selected</string>
<string name="confirmation_clear_camera_upload_sources_title">Clear selected folders</string>
<string name="confirmation_clear_camera_upload_sources_message">Automatic uploads will stop scanning phone folders until you add a folder again.</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Original file will be</string>
<string name="prefs_camera_upload_behaviour_title">Original file will be</string>
<string name="prefs_camera_upload_last_sync_title">Last synchronization</string>
<string name="prefs_camera_upload_source_paths_clear_title">Remove selected folders</string>
<string name="prefs_camera_upload_source_paths_empty">No phone folders selected</string>
<string name="confirmation_clear_camera_upload_sources_title">Remove selected folders</string>
<string name="confirmation_clear_camera_upload_sources_message">Backup will stop scanning phone folders until you add a folder again.</string>
<string name="prefs_camera_upload_allow_roaming">Allow backup while roaming</string>
<string name="prefs_camera_upload_allow_roaming_summary">Only applies when Wi-Fi-only backup is off</string>
<string name="prefs_camera_upload_mobile_data_limit_title">Mobile data daily limit</string>
<string name="prefs_camera_upload_mobile_data_limit_summary">Only applies when Wi-Fi-only backup is off and the active network is metered</string>
<string-array name="prefs_camera_upload_mobile_data_limit_entries">
<item>Unlimited</item>
<item>10 MB per day</item>
<item>50 MB per day</item>
<item>200 MB per day</item>
<item>1 GB per day</item>
</string-array>
<string-array name="prefs_camera_upload_mobile_data_limit_values">
<item>0</item>
<item>10485760</item>
<item>52428800</item>
<item>209715200</item>
<item>1073741824</item>
</string-array>
<string name="prefs_camera_upload_behaviour_dialog_title">Original file after backup</string>
<string name="prefs_camera_upload_behaviour_title">Original file after backup</string>
<string name="prefs_camera_upload_last_sync_title">Last backup scan</string>
<string name="prefs_camera_upload_last_sync_never">Waiting for first backup scan</string>
<string name="notifications_permission_denied">You can update your preferences in Settings</string>
<string name="upload_copy_files">Copy file</string>
<string name="upload_move_files">Move file</string>
@@ -64,6 +64,20 @@
app:iconSpaceReserved="false"
app:key="picture_uploads_on_wifi"
app:title="@string/prefs_camera_picture_upload_on_wifi" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="picture_uploads_allow_roaming"
app:summary="@string/prefs_camera_upload_allow_roaming_summary"
app:title="@string/prefs_camera_upload_allow_roaming" />
<ListPreference
app:entries="@array/prefs_camera_upload_mobile_data_limit_entries"
app:entryValues="@array/prefs_camera_upload_mobile_data_limit_values"
app:iconSpaceReserved="false"
app:key="picture_uploads_mobile_data_limit"
app:negativeButtonText=""
app:summary="@string/prefs_camera_upload_mobile_data_limit_summary"
app:title="@string/prefs_camera_upload_mobile_data_limit_title"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="picture_uploads_on_charging"
@@ -61,7 +61,22 @@
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="video_uploads_on_wifi"
app:summary="@string/prefs_camera_video_upload_on_wifi_summary"
app:title="@string/prefs_camera_video_upload_on_wifi" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="video_uploads_allow_roaming"
app:summary="@string/prefs_camera_upload_allow_roaming_summary"
app:title="@string/prefs_camera_upload_allow_roaming" />
<ListPreference
app:entries="@array/prefs_camera_upload_mobile_data_limit_entries"
app:entryValues="@array/prefs_camera_upload_mobile_data_limit_values"
app:iconSpaceReserved="false"
app:key="video_uploads_mobile_data_limit"
app:negativeButtonText=""
app:summary="@string/prefs_camera_upload_mobile_data_limit_summary"
app:title="@string/prefs_camera_upload_mobile_data_limit_title"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
app:iconSpaceReserved="false"
app:key="video_uploads_on_charging"
@@ -0,0 +1,38 @@
package eu.qsfera.android.usecases.transfers.uploads
import androidx.work.NetworkType
import org.junit.Assert.assertEquals
import org.junit.Test
class UploadFileFromContentUriUseCaseTest {
@Test
fun `network type is unmetered when wifi only is enabled`() {
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
wifiOnly = true,
allowRoaming = false
)
assertEquals(NetworkType.UNMETERED, networkType)
}
@Test
fun `network type is connected when mobile data and roaming are allowed`() {
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
wifiOnly = false,
allowRoaming = true
)
assertEquals(NetworkType.CONNECTED, networkType)
}
@Test
fun `network type avoids roaming when mobile data is allowed without roaming`() {
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
wifiOnly = false,
allowRoaming = false
)
assertEquals(NetworkType.NOT_ROAMING, networkType)
}
}
@@ -0,0 +1,143 @@
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
)
)
}
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,
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
@@ -31,7 +31,7 @@ public class ProviderMeta {
public static final String DB_NAME = "filelist";
public static final String NEW_DB_NAME = "qsfera_database";
public static final int DB_VERSION = 49;
public static final int DB_VERSION = 51;
private ProviderMeta() {
}
@@ -52,6 +52,8 @@ import eu.qsfera.android.data.migrations.MIGRATION_41_42
import eu.qsfera.android.data.migrations.MIGRATION_42_43
import eu.qsfera.android.data.migrations.MIGRATION_47_48
import eu.qsfera.android.data.migrations.MIGRATION_48_49
import eu.qsfera.android.data.migrations.MIGRATION_49_50
import eu.qsfera.android.data.migrations.MIGRATION_50_51
import eu.qsfera.android.data.sharing.shares.db.OCShareDao
import eu.qsfera.android.data.sharing.shares.db.OCShareEntity
import eu.qsfera.android.data.spaces.db.SpaceSpecialEntity
@@ -127,7 +129,9 @@ abstract class QSferaDatabase : RoomDatabase() {
MIGRATION_41_42,
MIGRATION_42_43,
MIGRATION_47_48,
MIGRATION_48_49)
MIGRATION_48_49,
MIGRATION_49_50,
MIGRATION_50_51)
.build()
INSTANCE = instance
instance
@@ -68,6 +68,10 @@ class OCLocalFolderBackupDataSource(
sourcePath = sourcePath,
uploadPath = uploadPath,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly,
name = name,
lastSyncTimestamp = lastSyncTimestamp,
@@ -83,6 +87,10 @@ class OCLocalFolderBackupDataSource(
sourcePath = sourcePath,
uploadPath = uploadPath,
wifiOnly = wifiOnly,
allowRoaming = allowRoaming,
mobileDataLimitBytes = mobileDataLimitBytes,
mobileDataPeriodStartTimestamp = mobileDataPeriodStartTimestamp,
mobileDataBytesQueuedInPeriod = mobileDataBytesQueuedInPeriod,
chargingOnly = chargingOnly,
lastSyncTimestamp = lastSyncTimestamp,
name = name,
@@ -29,6 +29,10 @@ data class FolderBackUpEntity(
val sourcePath: String,
val uploadPath: String,
val wifiOnly: Boolean,
val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean,
val name: String,
val lastSyncTimestamp: Long,
@@ -48,13 +48,17 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_ENABLED, false)) return null
return FolderBackUpConfiguration(
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME, null) ?: "",
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME, null).orEmpty(),
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY, false),
uploadPath = getUploadPathForPreference(PREF__CAMERA_PICTURE_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE),
behavior = getBehaviorForPreference(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR),
lastSyncTimestamp = timestamp,
name = pictureUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false,
spaceId = null,
)
@@ -64,13 +68,17 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
if (!sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_ENABLED, false)) return null
return FolderBackUpConfiguration(
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME, null) ?: "",
accountName = sharedPreferencesProvider.getString(PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME, null).orEmpty(),
wifiOnly = sharedPreferencesProvider.getBoolean(PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY, false),
uploadPath = getUploadPathForPreference(PREF__CAMERA_VIDEO_UPLOADS_PATH),
sourcePath = getSourcePathForPreference(PREF__CAMERA_VIDEO_UPLOADS_SOURCE),
behavior = getBehaviorForPreference(PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR),
lastSyncTimestamp = timestamp,
name = videoUploadsName,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = false,
spaceId = null,
)
@@ -85,7 +93,7 @@ class CameraUploadsMigrationToRoom(val sharedPreferencesProvider: SharedPreferen
}
private fun getSourcePathForPreference(keyPreference: String): String =
sharedPreferencesProvider.getString(keyPreference, null) ?: ""
sharedPreferencesProvider.getString(keyPreference, null).orEmpty()
private fun getBehaviorForPreference(keyPreference: String): UploadBehavior {
val storedBehaviour = sharedPreferencesProvider.getString(keyPreference, null) ?: return UploadBehavior.COPY
@@ -0,0 +1,11 @@
package eu.qsfera.android.data.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import eu.qsfera.android.data.ProviderMeta.ProviderTableMeta.FOLDER_BACKUP_TABLE_NAME
val MIGRATION_49_50 = object : Migration(49, 50) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN allowRoaming INTEGER NOT NULL DEFAULT '0'")
}
}
@@ -0,0 +1,13 @@
package eu.qsfera.android.data.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import eu.qsfera.android.data.ProviderMeta.ProviderTableMeta.FOLDER_BACKUP_TABLE_NAME
val MIGRATION_50_51 = object : Migration(50, 51) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataLimitBytes INTEGER NOT NULL DEFAULT '0'")
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataPeriodStartTimestamp INTEGER NOT NULL DEFAULT '0'")
database.execSQL("ALTER TABLE $FOLDER_BACKUP_TABLE_NAME ADD COLUMN mobileDataBytesQueuedInPeriod INTEGER NOT NULL DEFAULT '0'")
}
}
@@ -25,6 +25,10 @@ data class FolderBackUpConfiguration(
val sourcePath: String,
val uploadPath: String,
val wifiOnly: Boolean,
val allowRoaming: Boolean,
val mobileDataLimitBytes: Long,
val mobileDataPeriodStartTimestamp: Long,
val mobileDataBytesQueuedInPeriod: Long,
val chargingOnly: Boolean,
val lastSyncTimestamp: Long,
val name: String,
@@ -38,6 +42,8 @@ data class FolderBackUpConfiguration(
companion object {
const val pictureUploadsName = "Picture uploads"
const val videoUploadsName = "Video uploads"
const val initialSyncTimestamp = 0L
private const val SOURCE_PATH_SEPARATOR = "\n"
fun parseSourcePaths(sourcePath: String): List<String> =
sourcePath
@@ -52,8 +58,6 @@ data class FolderBackUpConfiguration(
.filter { it.isNotEmpty() }
.distinct()
.joinToString(SOURCE_PATH_SEPARATOR)
private const val SOURCE_PATH_SEPARATOR = "\n"
}
}
@@ -31,6 +31,10 @@ val OC_BACKUP = FolderBackUpConfiguration(
sourcePath = "/Photos",
uploadPath = "/Photos",
wifiOnly = true,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true,
lastSyncTimestamp = 1542628397,
name = "",
@@ -43,6 +47,10 @@ val OC_BACKUP_ENTITY = FolderBackUpEntity(
sourcePath = "/Photos",
uploadPath = "/Photos",
wifiOnly = true,
allowRoaming = false,
mobileDataLimitBytes = 0,
mobileDataPeriodStartTimestamp = 0,
mobileDataBytesQueuedInPeriod = 0,
chargingOnly = true,
lastSyncTimestamp = 1542628397,
name = "",
@@ -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,277 @@
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',
[switch]$Unlisted,
[switch]$SkipSigningInitialization
)
$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
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$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
$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 }
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"
+141
View File
@@ -0,0 +1,141 @@
param(
[string]$BaseUrl = 'https://argus.kusoft.xyz',
[Parameter(Mandatory = $true)]
[string]$Username,
[Parameter(Mandatory = $true)]
[string]$Password,
[Parameter(Mandatory = $true)]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Summary,
[Parameter(Mandatory = $true)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$AndroidPackageName,
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[ValidateRange(1, [int]::MaxValue)]
[int]$AndroidVersionCode,
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[string]$Channel = 'stable',
[string]$Platform = 'android',
[string]$PackageKind = 'apk',
[string]$Notes,
[string]$RepositoryUrl,
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[switch]$Unlisted
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Net.Http
function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) {
if ($null -eq $Response.Content) {
return ''
}
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Ensure-Success(
[System.Net.Http.HttpResponseMessage]$Response,
[string]$Operation
) {
if ($Response.IsSuccessStatusCode) {
return
}
$body = Read-HttpBody $Response
throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body"
}
function New-JsonContent([string]$Json) {
return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json')
}
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) {
throw "Package file was not found: $resolvedPackagePath"
}
$baseUri = $BaseUrl.TrimEnd('/')
$cookieContainer = [System.Net.CookieContainer]::new()
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.CookieContainer = $cookieContainer
$handler.UseCookies = $true
$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromMinutes(15)
try {
$loginPayload = @{
username = $Username
password = $Password
} | ConvertTo-Json -Compress
$loginResponse = $client.PostAsync(
"$baseUri/api/admin/session/login",
(New-JsonContent $loginPayload)
).GetAwaiter().GetResult()
Ensure-Success $loginResponse 'Argus login'
$appPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl }
homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl }
isListed = (-not $Unlisted)
} | ConvertTo-Json -Compress
$appResponse = $client.PutAsync(
"$baseUri/api/admin/apps/$Slug",
(New-JsonContent $appPayload)
).GetAwaiter().GetResult()
Ensure-Success $appResponse 'Argus app metadata update'
$form = [System.Net.Http.MultipartFormDataContent]::new()
$fields = [ordered]@{
Version = $Version
Channel = $Channel
Platform = $Platform
PackageKind = $PackageKind
AndroidVersionCode = [string]$AndroidVersionCode
}
foreach ($entry in $fields.GetEnumerator()) {
$form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key)
}
if (-not [string]::IsNullOrWhiteSpace($Notes)) {
$form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes')
}
$fileStream = [System.IO.File]::OpenRead($resolvedPackagePath)
try {
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath))
$releaseResponse = $client.PostAsync(
"$baseUri/api/admin/apps/$Slug/releases",
$form
).GetAwaiter().GetResult()
Ensure-Success $releaseResponse 'Argus release upload'
$releaseBody = Read-HttpBody $releaseResponse
if (-not [string]::IsNullOrWhiteSpace($releaseBody)) {
Write-Output $releaseBody
}
} finally {
$fileStream.Dispose()
$form.Dispose()
}
} finally {
$client.Dispose()
$handler.Dispose()
}
+16
View File
@@ -57,6 +57,8 @@ jobs:
CRAFT_TARGET: ${{ matrix.target }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CRAFT_PACKAGE_SYMBOLS: ${{ github.event_name != 'pull_request' }}
SIGN_PACKAGE: ${{ secrets.QSFERA_DESKTOP_SIGN_PACKAGE }}
CRAFT_CODESIGN_CERTIFICATE: ${{ secrets.QSFERA_DESKTOP_CODESIGN_CERTIFICATE }}
container: ${{ matrix.container }}
@@ -188,11 +190,25 @@ jobs:
- name: Package
if: matrix.target != 'macos-clang-arm64'
run: |
$requireSigning = "${{ matrix.os }}" -eq "windows-latest" -and "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta"
if ("${{ matrix.os }}" -eq "windows-latest") {
$validationArgs = @()
if ($requireSigning) {
$validationArgs += "-RequireSigning"
}
& "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs
}
& "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package opencloud/opencloud-desktop
- name: Package Appx
if: ${{ matrix.os == 'windows-latest' && github.event_name != 'pull_request' }}
run: |
$requireSigning = "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta"
$validationArgs = @()
if ($requireSigning) {
$validationArgs += "-RequireSigning"
}
& "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs
& "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package --options "[Packager]PackageType=AppxPackager" --options "[Packager]Destination=${{ github.workspace }}/appx/" opencloud/opencloud-desktop
- name: Prepare artifacts
+1
View File
@@ -5,6 +5,7 @@ set( APPLICATION_VENDOR "QSfera" )
set( APPLICATION_ICON_NAME "qsfera" )
set( APPLICATION_REV_DOMAIN "eu.qsfera.desktop" )
set( APPLICATION_DEFAULT_SERVER_URL "https://qsfera.kusoft.xyz" )
set( APPLICATION_UPDATE_URL "https://qsfera.kusoft.xyz/desktop/updates/stable.xml" )
if(BETA_CHANNEL_BUILD)
set( APPLICATION_NAME "${APPLICATION_NAME} Beta")
+65
View File
@@ -0,0 +1,65 @@
# Windows Update Feed
QSfera Desktop uses the built-in Windows updater from `src/gui/updater`.
The updater reads `APPLICATION_UPDATE_URL`, appends query parameters such as
`version`, `platform=win32`, and `msi=true`, and expects an XML document with
root element `QSfera`.
Production feed URL:
```text
https://qsfera.kusoft.xyz/desktop/updates/stable.xml
```
The no-update feed is valid when no signed Windows MSI is available yet:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<QSfera>
<version></version>
<versionstring></versionstring>
<web>https://qsfera.kusoft.xyz/</web>
<downloadurl></downloadurl>
</QSfera>
```
To advertise a signed MSI update, publish the MSI over HTTPS and replace the
feed with a concrete version:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<QSfera>
<version>1.3.7</version>
<versionstring>1.3.7</versionstring>
<web>https://qsfera.kusoft.xyz/</web>
<downloadurl>https://qsfera.kusoft.xyz/desktop/downloads/QSfera_Desktop-1.3.7-x64.msi</downloadurl>
</QSfera>
```
From the repository root, prepare the feed XML from a signed MSI and optionally
upload the MSI/feed to the web host:
```powershell
.\scripts\package-windows-desktop.ps1
.\scripts\publish-windows-desktop.ps1 -MsiPath ".\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi"
.\scripts\publish-windows-desktop.ps1 `
-MsiPath ".\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi" `
-RemoteHost "192.168.0.185" `
-RemoteDownloadDirectory "/var/www/qsfera/desktop/downloads" `
-RemoteFeedPath "/var/www/qsfera/desktop/updates/stable.xml"
```
`publish-windows-desktop.ps1` refuses remote publication when the MSI is not
Authenticode-signed. `-SkipSignatureCheck` is only for local feed validation and
is rejected when `-RemoteHost` is set.
From the repository root, validate the feed before a Windows production release:
```powershell
.\Desktop\scripts\validate-desktop-update-feed.ps1 -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
.\Desktop\scripts\validate-desktop-update-feed.ps1 -Path ".\artifacts\windows-desktop\publish\stable.xml"
.\Desktop\scripts\validate-production-release.ps1 -VerifyUpdateFeed
```
For a stable signed tag release, `validate-production-release.ps1 -RequireSigning`
also verifies this live feed.
@@ -0,0 +1,104 @@
[CmdletBinding(DefaultParameterSetName = "Url")]
param(
[Parameter(Mandatory = $true, ParameterSetName = "Url")]
[string]$Url,
[Parameter(Mandatory = $true, ParameterSetName = "Path")]
[string]$Path
)
$ErrorActionPreference = "Stop"
function Fail($Message) {
Write-Error $Message
exit 1
}
$feedSource = $null
$feedContent = $null
if ($PSCmdlet.ParameterSetName -eq "Url") {
$parsedUrl = $null
if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$parsedUrl)) {
Fail "Desktop update feed URL is not absolute: $Url"
}
if ($parsedUrl.Scheme -ne "https") {
Fail "Desktop update feed URL must use HTTPS: $Url"
}
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "Desktop update feed request failed for ${Url}: $($_.Exception.Message)"
}
if ($response.StatusCode -ne 200) {
Fail "Desktop update feed must return HTTP 200. Actual status: $($response.StatusCode)"
}
$feedSource = $Url
$feedContent = $response.Content
} else {
if (-not (Test-Path -LiteralPath $Path)) {
Fail "Desktop update feed file was not found: $Path"
}
$feedSource = (Resolve-Path -LiteralPath $Path).Path
$feedContent = Get-Content -LiteralPath $feedSource -Raw
}
try {
[xml]$xml = $feedContent
} catch {
Fail "Desktop update feed is not valid XML: $($_.Exception.Message)"
}
if ($xml.DocumentElement.Name -ne "QSfera") {
Fail "Desktop update feed root element must be <QSfera>."
}
$requiredElements = @("version", "versionstring", "web", "downloadurl")
foreach ($name in $requiredElements) {
if ($null -eq $xml.QSfera.$name) {
Fail "Desktop update feed is missing <$name>."
}
}
$version = [string]$xml.QSfera.version
$versionString = [string]$xml.QSfera.versionstring
$web = [string]$xml.QSfera.web
$downloadUrl = [string]$xml.QSfera.downloadurl
if (-not [string]::IsNullOrWhiteSpace($web)) {
$webUri = $null
if (-not [System.Uri]::TryCreate($web, [System.UriKind]::Absolute, [ref]$webUri) -or $webUri.Scheme -ne "https") {
Fail "Desktop update feed <web> must be an absolute HTTPS URL when present."
}
}
if ([string]::IsNullOrWhiteSpace($version)) {
if (-not [string]::IsNullOrWhiteSpace($downloadUrl)) {
Fail "Desktop no-update feed must leave <downloadurl> empty when <version> is empty."
}
} else {
$parsedVersion = $null
if (-not [System.Version]::TryParse($version, [ref]$parsedVersion)) {
Fail "Desktop update feed <version> must be parseable as a version number."
}
if ([string]::IsNullOrWhiteSpace($versionString)) {
Fail "Desktop update feed must set <versionstring> when <version> is present."
}
$downloadUri = $null
if (-not [System.Uri]::TryCreate($downloadUrl, [System.UriKind]::Absolute, [ref]$downloadUri) -or $downloadUri.Scheme -ne "https") {
Fail "Desktop update feed <downloadurl> must be an absolute HTTPS URL when an update is advertised."
}
if (-not $downloadUri.AbsolutePath.EndsWith(".msi", [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "Desktop Windows updater expects an MSI download URL."
}
}
Write-Host "QSfera Desktop update feed validation passed: $feedSource"
@@ -0,0 +1,76 @@
param(
[switch]$RequireSigning,
[switch]$VerifyUpdateFeed,
[string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
)
$ErrorActionPreference = "Stop"
function Fail($Message) {
Write-Error $Message
exit 1
}
function Read-Text($RelativePath) {
Get-Content -LiteralPath (Join-Path $Root $RelativePath) -Raw -Encoding UTF8
}
$branding = Read-Text "QSFERA.cmake"
$cmake = Read-Text "CMakeLists.txt"
$craft = Read-Text ".craft.ini"
$expectedAppName = -join ([char[]](0x041a, 0x0443, 0x0421, 0x0444, 0x0435, 0x0440, 0x0430))
if (-not $branding.Contains("APPLICATION_NAME") -or -not $branding.Contains($expectedAppName)) {
Fail "Desktop branding must use the production Cyrillic application name."
}
if ($branding -notmatch 'APPLICATION_EXECUTABLE\s+"qsfera"') {
Fail "Desktop executable must remain `"qsfera`" for the production profile."
}
if ($branding -notmatch 'APPLICATION_REV_DOMAIN\s+"eu\.qsfera\.desktop"') {
Fail "Desktop reverse-domain id must remain eu.qsfera.desktop for the production profile."
}
if ($branding -notmatch 'APPLICATION_DEFAULT_SERVER_URL\s+"https://qsfera\.kusoft\.xyz"') {
Fail "Desktop default server URL must be https://qsfera.kusoft.xyz."
}
if ($branding -notmatch 'APPLICATION_UPDATE_URL\s+"https://qsfera\.kusoft\.xyz/desktop/updates/stable\.xml"') {
Fail "Desktop update feed URL must be https://qsfera.kusoft.xyz/desktop/updates/stable.xml."
}
if ($cmake -notmatch 'option\(WITH_UPDATE_NOTIFICATION\s+"Whether to check for new releases"\s+ON\)') {
Fail "Desktop release builds must keep update notifications enabled by default."
}
if ($cmake -notmatch 'VIRTUAL_FILE_SYSTEM_PLUGINS\s+off\s+cfapi\s+openvfs') {
Fail "Desktop release builds must include the Windows CfAPI VFS plugin."
}
if ($craft -notmatch 'CodeSigning/Enabled\s*=\s*\$\{Env:SIGN_PACKAGE\}') {
Fail "Craft code-signing must be controlled by SIGN_PACKAGE."
}
if ($RequireSigning) {
if ($env:SIGN_PACKAGE -notin @("True", "true", "1", "yes", "YES")) {
Fail "Production Windows tag releases require SIGN_PACKAGE=True."
}
if ([string]::IsNullOrWhiteSpace($env:CRAFT_CODESIGN_CERTIFICATE)) {
Fail "Production Windows tag releases require CRAFT_CODESIGN_CERTIFICATE."
}
$VerifyUpdateFeed = $true
}
if ($VerifyUpdateFeed) {
$feedValidator = Join-Path $PSScriptRoot "validate-desktop-update-feed.ps1"
if (-not (Test-Path -LiteralPath $feedValidator)) {
Fail "Desktop update feed validator was not found: $feedValidator"
}
& $feedValidator -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
}
Write-Host "QSfera Desktop production release validation passed."
+4 -4
View File
@@ -194,13 +194,13 @@ void AccountSettings::slotRemoveCurrentFolder(Folder *folder)
{
// TODO: move to qml
qCInfo(lcAccountSettings) << u"Remove Folder " << folder->path();
auto messageBox = new FontIconMessageBox({Resources::FontIcon::DefaultGlyphes::Question}, tr("Confirm removal of Space"),
tr("<p>Do you really want to stop syncing the Space »%1«?</p>"
"<p><b>Note:</b> This will <b>not</b> delete any files.</p>")
auto messageBox = new FontIconMessageBox({Resources::FontIcon::DefaultGlyphes::Question}, tr("Confirm removal of folder sync"),
tr("<p>Stop syncing <b>%1</b> on this computer?</p>"
"<p><b>Note:</b> This will not delete files in QSfera or on this device.</p>")
.arg(folder->displayName()),
QMessageBox::NoButton, ocApp()->settingsDialog());
messageBox->setAttribute(Qt::WA_DeleteOnClose);
QPushButton *yesButton = messageBox->addButton(tr("Remove Space"), QMessageBox::YesRole);
QPushButton *yesButton = messageBox->addButton(tr("Remove folder sync"), QMessageBox::YesRole);
messageBox->addButton(tr("Cancel"), QMessageBox::NoRole);
connect(messageBox, &QMessageBox::finished, this, [messageBox, yesButton, folder, this] {
if (messageBox->clickedButton() == yesButton) {
+2 -2
View File
@@ -55,7 +55,7 @@
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Preparing the account</string>
<string>Preparing account</string>
</property>
</widget>
</item>
@@ -83,7 +83,7 @@
</widget>
<widget class="OCC::QmlUtils::OCQuickWidget" name="quickWidget">
<property name="accessibleName">
<string>Sync connections</string>
<string>Synchronized folders</string>
</property>
</widget>
</widget>
+50 -11
View File
@@ -48,8 +48,11 @@
#endif
#include <QApplication>
#include <QCursor>
#include <QDesktopServices>
#include <QGuiApplication>
#include <QMenuBar>
#include <QScreen>
using namespace Qt::Literals::StringLiterals;
using namespace OCC;
@@ -97,6 +100,32 @@ void setUpInitialSyncFolder(AccountStatePtr accountStatePtr, bool useVfs)
Qt::SingleShotConnection);
accountStatePtr->account()->spacesManager()->checkReady();
}
void ensureWindowIntersectsAvailableScreen(QWidget *window)
{
if (!window) {
return;
}
const auto frame = window->frameGeometry();
for (const auto *screen : QGuiApplication::screens()) {
if (screen->availableGeometry().intersects(frame)) {
return;
}
}
auto *screen = QGuiApplication::screenAt(QCursor::pos());
if (!screen) {
screen = QGuiApplication::primaryScreen();
}
if (!screen) {
return;
}
const auto availableGeometry = screen->availableGeometry();
window->resize(window->size().boundedTo(availableGeometry.size()));
window->move(availableGeometry.center() - window->rect().center());
}
}
QString Application::displayLanguage() const
@@ -244,23 +273,33 @@ AccountStatePtr Application::addNewAccount(AccountPtr newAccount)
void Application::showSettings()
{
auto window = ocApp()->settingsDialog();
ensureWindowIntersectsAvailableScreen(window);
if (window->windowState().testFlag(Qt::WindowMinimized)) {
window->setWindowState(window->windowState() & ~Qt::WindowMinimized);
}
window->show();
ensureWindowIntersectsAvailableScreen(window);
window->raise();
window->activateWindow();
#if defined(Q_OS_WIN)
// Windows disallows raising a Window when you're not the active application.
// Use a common hack to attach to the active application
const auto activeProcessId = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
if (activeProcessId != qApp->applicationPid()) {
const auto threadId = GetCurrentThreadId();
// don't step here with a debugger...
if (AttachThreadInput(threadId, activeProcessId, true)) {
const auto hwnd = reinterpret_cast<HWND>(window->winId());
SetForegroundWindow(hwnd);
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
AttachThreadInput(threadId, activeProcessId, false);
}
// Use a common hack to attach to the active application.
const auto foregroundWindow = GetForegroundWindow();
const auto foregroundThreadId = foregroundWindow ? GetWindowThreadProcessId(foregroundWindow, nullptr) : 0;
const auto currentThreadId = GetCurrentThreadId();
const auto attachedToForegroundThread =
foregroundThreadId != 0 && foregroundThreadId != currentThreadId && AttachThreadInput(currentThreadId, foregroundThreadId, true);
const auto hwnd = reinterpret_cast<HWND>(window->winId());
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
if (attachedToForegroundThread) {
AttachThreadInput(currentThreadId, foregroundThreadId, false);
}
#endif
}
+6 -6
View File
@@ -77,7 +77,7 @@
<item row="0" column="0">
<widget class="QCheckBox" name="autostartCheckBox">
<property name="text">
<string>Start on Login</string>
<string>Start QSfera when I sign in</string>
</property>
</widget>
</item>
@@ -95,7 +95,7 @@
<item>
<widget class="QCheckBox" name="syncHiddenFilesCheckBox">
<property name="text">
<string>Sync hidden files</string>
<string>Sync hidden files and folders</string>
</property>
</widget>
</item>
@@ -108,14 +108,14 @@
</sizepolicy>
</property>
<property name="text">
<string>Show crash reporter</string>
<string>Show crash reporter after a crash</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="moveToTrashCheckBox">
<property name="text">
<string>Move remotely deleted files to the local trash bin instead of deleting them</string>
<string>Move server-deleted files to the local trash instead of deleting them</string>
</property>
</widget>
</item>
@@ -124,14 +124,14 @@
<item>
<widget class="QPushButton" name="ignoredFilesButton">
<property name="text">
<string>Edit Ignored Files</string>
<string>Ignored files</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="logSettingsButton">
<property name="text">
<string>Log Settings</string>
<string>Logs</string>
</property>
</widget>
</item>
+8 -8
View File
@@ -22,14 +22,14 @@ import eu.QSfera.resources 1.0
Pane {
id: folderSyncPanel
// TODO: not cool
readonly property real normalSize: 170
readonly property real normalSize: 140
readonly property AccountSettings accountSettings: ocContext
readonly property OCQuickWidget widget: ocQuickWidget
spacing: 10
Accessible.role: Accessible.List
Accessible.name: qsTr("Folder Sync")
Accessible.name: qsTr("Synchronized folders")
Connections {
target: widget
@@ -69,7 +69,7 @@ Pane {
Button {
id: manageAccountButton
text: qsTr("Manage Account")
text: qsTr("Account")
Menu {
id: accountMenu
@@ -89,14 +89,14 @@ Pane {
}
MenuItem {
text: qsTr("Remove")
text: qsTr("Remove account")
onTriggered: accountSettings.slotDeleteAccount()
}
}
onClicked: {
accountMenu.open();
Accessible.announce(qsTr("Account options Menu"));
Accessible.announce(qsTr("Account options menu"));
}
Keys.onBacktabPressed: {
@@ -352,7 +352,7 @@ Pane {
}
MenuItem {
text: qsTr("Remove Space")
text: qsTr("Remove folder sync")
onTriggered: accountSettings.slotRemoveCurrentFolder(folderDelegate.folder)
visible: !folderDelegate.isDeployed
height: visible ? implicitHeight : 0
@@ -371,7 +371,7 @@ Pane {
Button {
id: addSyncButton
text: qsTr("Add Space")
text: qsTr("Add folder sync")
onClicked: {
accountSettings.slotAddFolder();
@@ -391,7 +391,7 @@ Pane {
Layout.fillWidth: true
}
Label {
text: qsTr("You are synchronizing %1 out of %2 Spaces").arg(accountSettings.syncedSpaces).arg(accountSettings.syncedSpaces + accountSettings.unsyncedSpaces)
text: qsTr("Synchronized folders: %1 of %2").arg(accountSettings.syncedSpaces).arg(accountSettings.syncedSpaces + accountSettings.unsyncedSpaces)
visible: accountSettings.accountState.state === AccountState.Connected
}
}
+9 -3
View File
@@ -24,6 +24,7 @@ Item {
property alias imageSource: image.source
property alias statusSource: statusIcon.source
property real imageSize: 96
property real spacing: 10
default property alias content: colLayout.data
@@ -39,15 +40,20 @@ Item {
Rectangle {
Accessible.ignored: true
Layout.preferredHeight: normalSize - 20
Layout.preferredWidth: normalSize - 20
Layout.preferredHeight: imageSize
Layout.preferredWidth: imageSize
Layout.alignment: Qt.AlignTop
color: spaceDelegate.palette.alternateBase
radius: 6
border.color: spaceDelegate.palette.mid
border.width: 1
clip: true
Image {
id: image
anchors.fill: parent
anchors.margins: 8
fillMode: Image.PreserveAspectFit
sourceSize.width: width
sourceSize.height: height
@@ -74,7 +80,7 @@ Item {
Accessible.ignored: true
Layout.fillWidth: true
font.bold: true
font.pointSize: 15
font.pointSize: 13
elide: Text.ElideRight
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ import eu.QSfera.libsync 1.0
Pane {
id: spacesView
// TODO: not cool
readonly property real normalSize: 170
readonly property real normalSize: 140
readonly property SpacesBrowser spacesBrowser: ocContext
readonly property OCQuickWidget widget: ocQuickWidget
+1 -1
View File
@@ -331,7 +331,7 @@ void ConfigFile::setSkipUpdateCheck(bool skip)
QString ConfigFile::updateChannel() const
{
auto settings = makeQSettings();
return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"stable"_s : u"beta"_s).toString();
return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"beta"_s : u"stable"_s).toString();
}
void ConfigFile::setUpdateChannel(const QString &channel)
+8 -2
View File
@@ -27,6 +27,7 @@
#include "csync.h"
#include "syncfileitem.h"
#include <chrono>
#include <sys/stat.h>
#ifdef Q_OS_WIN32
@@ -74,7 +75,9 @@ time_t FileSystem::fileTimeToTime_t(std::filesystem::file_time_type fileTime)
#ifdef HAS_CLOCK_CAST
return std::chrono::system_clock::to_time_t(std::chrono::clock_cast<std::chrono::system_clock>(fileTime));
#else
const auto systemTime = std::chrono::time_point_cast<std::chrono::system_clock::duration>(std::chrono::file_clock::to_sys(fileTime));
const auto fileNow = std::filesystem::file_time_type::clock::now();
const auto systemNow = std::chrono::system_clock::now();
const auto systemTime = std::chrono::time_point_cast<std::chrono::system_clock::duration>(systemNow + (fileTime - fileNow));
return std::chrono::system_clock::to_time_t(systemTime);
#endif
}
@@ -83,7 +86,10 @@ std::filesystem::file_time_type FileSystem::time_tToFileTime(time_t fileTime)
#ifdef HAS_CLOCK_CAST
return std::chrono::clock_cast<std::chrono::file_clock>(std::chrono::system_clock::from_time_t(fileTime));
#else
return std::chrono::file_clock::from_sys(std::chrono::system_clock::from_time_t(fileTime));
const auto fileNow = std::filesystem::file_time_type::clock::now();
const auto systemNow = std::chrono::system_clock::now();
return std::chrono::time_point_cast<std::filesystem::file_time_type::duration>(
fileNow + (std::chrono::system_clock::from_time_t(fileTime) - systemNow));
#endif
}
+4 -2
View File
@@ -33,8 +33,10 @@ Resources::Icon Resources::parseIcon(const QString &id)
QPixmap Resources::pixmap(const QSize &requestedSize, const QIcon &icon, QIcon::Mode mode, QSize *outSize)
{
Q_ASSERT(requestedSize.isValid());
QSize actualSize = requestedSize.isValid() ? requestedSize : icon.availableSizes().constFirst();
QSize actualSize = requestedSize.isValid() && !requestedSize.isEmpty() ? requestedSize : icon.actualSize(minIconSize);
if (!actualSize.isValid() || actualSize.isEmpty()) {
actualSize = minIconSize;
}
if (outSize) {
*outSize = actualSize;
}
+1
View File
@@ -13,6 +13,7 @@ qsfera_add_test(ConcatUrl)
qsfera_add_test(XmlParse)
qsfera_add_test(ChecksumValidator)
qsfera_add_test(ConnectionValidator)
qsfera_add_test(ConfigFile)
# TODO: we need keychain access for this test
@@ -23,6 +23,6 @@ Feature: remove account connection
And user "Alice" has set up a client with default settings
When the user removes the connection for user "Alice"
Then the settings tab should have the following options in the general section:
| Start on Login |
| Start QSfera when I sign in |
And the folder "large-folder" should exist on the file system
And the file "testFile.txt" should exist on the file system
@@ -19,11 +19,11 @@ Feature: Visually check all tabs
And user "Alice" has set up a client with default settings
When the user opens the settings tab
Then the settings tab should have the following options in the general section:
| Start on Login |
| Start QSfera when I sign in |
And the settings tab should have the following options in the advanced section:
| Sync hidden files |
| Edit ignored files |
| Log settings |
| Sync hidden files and folders |
| Ignored files |
| Logs |
And the settings tab should have the following options in the network section:
| Download Bandwidth |
| Upload Bandwidth |
@@ -11,16 +11,16 @@ from helpers.SyncHelper import wait_for
class AccountSetting:
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
by=By.NAME, selector="Sync connections"
by=By.NAME, selector="Synchronized folders"
)
MANAGE_ACCOUNT_BUTTON = SimpleNamespace(by=By.NAME, selector="Manage Account")
MANAGE_ACCOUNT_BUTTON = SimpleNamespace(by=By.NAME, selector="Account")
ACCOUNT_MENU = SimpleNamespace(by=By.NAME, selector="{menu_item}")
CONFIRM_REMOVE_CONNECTION_BUTTON = SimpleNamespace(
by=By.NAME, selector="Remove connection"
)
ACCOUNT_CONNECTION_LABEL = SimpleNamespace(
by=By.XPATH,
selector="//list[@name='Folder Sync']//label",
selector="//list[@name='Synchronized folders']//label",
)
LOG_BROWSER_WINDOW = SimpleNamespace(by=None, selector=None)
ACCOUNT_LOADING = SimpleNamespace(by=None, selector=None)
@@ -50,7 +50,7 @@ class AccountSetting:
@staticmethod
def remove_account_connection():
AccountSetting.account_action("Remove")
AccountSetting.account_action("Remove account")
app().find_element(
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.by,
AccountSetting.CONFIRM_REMOVE_CONNECTION_BUTTON.selector,
+8 -8
View File
@@ -11,19 +11,19 @@ class Settings:
ABOUT_DIALOG = SimpleNamespace(by=By.CLASS_NAME, selector="[page tab | About]")
ABOUT_DIALOG_OK_BUTTON = SimpleNamespace(by=By.NAME, selector="OK")
GENERAL_SETTING_START_ON_LOGIN = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Start on Login']"
by=By.XPATH, selector="//panel/*[@name='Start QSfera when I sign in']"
)
GENERAL_SETTING_LANGUAGE = SimpleNamespace(
by=By.XPATH, selector="//panel/label[@name='Language']"
)
ADVANCED_SETTING_SYNC_HIDDEN_FILES = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Sync hidden files']"
by=By.XPATH, selector="//panel/*[@name='Sync hidden files and folders']"
)
ADVANCED_SETTING_EDIT_IGNORED_FILES = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Edit Ignored Files']"
by=By.XPATH, selector="//panel/*[@name='Ignored files']"
)
ADVANCED_SETTING_LOG_SETTINGS = SimpleNamespace(
by=By.XPATH, selector="//panel/*[@name='Log Settings']"
by=By.XPATH, selector="//panel/*[@name='Logs']"
)
NETWORK_SETTING_DOWNLOAD_BANDWIDTH = SimpleNamespace(
by=By.XPATH, selector="//panel[@name='Download Bandwidth']"
@@ -50,7 +50,7 @@ class Settings:
@staticmethod
def has_general_setting(setting):
if setting.lower() == "start on login":
if setting.lower() == "start qsfera when i sign in":
locator = Settings.GENERAL_SETTING_START_ON_LOGIN
elif setting.lower() == "language":
locator = Settings.GENERAL_SETTING_LANGUAGE
@@ -60,11 +60,11 @@ class Settings:
@staticmethod
def has_advanced_setting(setting):
if setting.lower() == "sync hidden files":
if setting.lower() == "sync hidden files and folders":
locator = Settings.ADVANCED_SETTING_SYNC_HIDDEN_FILES
elif setting.lower() == "edit ignored files":
elif setting.lower() == "ignored files":
locator = Settings.ADVANCED_SETTING_EDIT_IGNORED_FILES
elif setting.lower() == "log settings":
elif setting.lower() == "logs":
locator = Settings.ADVANCED_SETTING_LOG_SETTINGS
else:
raise ValueError(f"Unknown advanced setting: {setting}")
@@ -8,7 +8,7 @@ from helpers.AppHelper import app
class SyncConnection:
ACCOUNT_CONNECTION_CONTAINER = SimpleNamespace(
by=By.NAME, selector="Sync connections"
by=By.NAME, selector="Synchronized folders"
)
FOLDER_SYNC_CONNECTION_MENU_BUTTON = SimpleNamespace(
by=By.NAME,
@@ -18,7 +18,7 @@ class SyncConnection:
SELECTIVE_SYNC_APPLY_BUTTON = SimpleNamespace(by=None, selector=None)
CANCEL_FOLDER_SYNC_CONNECTION_DIALOG = SimpleNamespace(by=None, selector=None)
CONFIRM_FOLDER_SYNC_CONNECTION_REMOVE = SimpleNamespace(
by=By.NAME, selector="Remove Space"
by=By.NAME, selector="Remove folder sync"
)
PERMISSION_ERROR_LABEL = SimpleNamespace(by=None, selector=None)
@@ -98,7 +98,7 @@ class SyncConnection:
@staticmethod
def remove_folder_sync_connection():
SyncConnection.perform_action("Remove Space")
SyncConnection.perform_action("Remove folder sync")
@staticmethod
def cancel_folder_sync_connection_removal():
@@ -35,7 +35,7 @@
```
- If not stated otherwise each test should be run on every platform win/linux/mac. Record the result as per platform.
- "Enable logging to temporary folder" and "Log Http traffic" (tab 'Settings', button 'Log Settings') to have log-files available if needed to report an issue.
- "Enable logging to temporary folder" and "Log Http traffic" (tab 'Settings', button 'Logs') to have log-files available if needed to report an issue.
- Make sure all automated UI tests run successfully in CI and run them locally on Windows.
- all tests that are automated are marked with a :robot: in the 'Result' column (for the corresponding platform) and the name of the test in the 'Related Comment' column
- If multiple people are working on the tests at the same time, mark the tests everyone is working on with the GitHub name of the tester.
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (C) QSfera
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
#include "common/version.h"
#include "configfile.h"
#include <QFile>
#include <QTemporaryDir>
#include <QtTest>
using namespace OCC;
using namespace Qt::Literals::StringLiterals;
class TestConfigFile : public QObject
{
Q_OBJECT
private Q_SLOTS:
void init()
{
QVERIFY(_configDir.isValid());
QVERIFY(ConfigFile::setConfDir(_configDir.path()));
QFile::remove(ConfigFile::configFile());
}
void testDefaultUpdateChannelMatchesBuildChannel()
{
const auto expectedChannel = Version::isBeta() ? u"beta"_s : u"stable"_s;
QCOMPARE(ConfigFile().updateChannel(), expectedChannel);
}
void testStoredUpdateChannelOverridesBuildDefault()
{
ConfigFile().setUpdateChannel(u"beta"_s);
QCOMPARE(ConfigFile().updateChannel(), u"beta"_s);
ConfigFile().setUpdateChannel(u"stable"_s);
QCOMPARE(ConfigFile().updateChannel(), u"stable"_s);
}
private:
QTemporaryDir _configDir;
};
QTEST_MAIN(TestConfigFile)
#include "testconfigfile.moc"
+18 -18
View File
@@ -230,12 +230,12 @@ File synchronization desktop utility.</source>
<name>FolderDelegate</name>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
<source>Folder Sync</source>
<source>Synchronized folders</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
<source>Manage Account</source>
<source>Account</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -255,12 +255,12 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
<source>Remove</source>
<source>Remove account</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
<source>Account options Menu</source>
<source>Account options menu</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -290,7 +290,7 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
<source>Remove Space</source>
<source>Remove folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -300,12 +300,12 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
<source>Add Space</source>
<source>Add folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="394"/>
<source>You are synchronizing %1 out of %2 Spaces</source>
<source>Synchronized folders: %1 of %2</source>
<translation type="unfinished"></translation>
</message>
</context>
@@ -601,12 +601,12 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="58"/>
<source>Preparing the account</source>
<source>Preparing account</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="86"/>
<source>Sync connections</source>
<source>Synchronized folders</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -622,17 +622,17 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="197"/>
<source>Confirm removal of Space</source>
<source>Confirm removal of folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="198"/>
<source>&lt;p&gt;Do you really want to stop syncing the Space »%1«?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will &lt;b&gt;not&lt;/b&gt; delete any files.&lt;/p&gt;</source>
<source>&lt;p&gt;Stop syncing &lt;b&gt;%1&lt;/b&gt; on this computer?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will not delete files in QSfera or on this device.&lt;/p&gt;</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="203"/>
<source>Remove Space</source>
<source>Remove folder sync</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -1269,7 +1269,7 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="80"/>
<source>Start on Login</source>
<source>Start QSfera when I sign in</source>
<translation type="unfinished"></translation>
</message>
<message>
@@ -1284,27 +1284,27 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="98"/>
<source>Sync hidden files</source>
<source>Sync hidden files and folders</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="111"/>
<source>Show crash reporter</source>
<source>Show crash reporter after a crash</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="118"/>
<source>Move remotely deleted files to the local trash bin instead of deleting them</source>
<source>Move server-deleted files to the local trash instead of deleting them</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="127"/>
<source>Edit Ignored Files</source>
<source>Ignored files</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="134"/>
<source>Log Settings</source>
<source>Logs</source>
<translation type="unfinished"></translation>
</message>
<message>
+37 -37
View File
@@ -231,13 +231,13 @@ File synchronization desktop utility.</source>
<name>FolderDelegate</name>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="32"/>
<source>Folder Sync</source>
<translation>Синхронизация папки</translation>
<source>Synchronized folders</source>
<translation>Синхронизированные папки</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="72"/>
<source>Manage Account</source>
<translation>Управление аккаунтом</translation>
<source>Account</source>
<translation>Аккаунт</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="78"/>
@@ -256,13 +256,13 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="92"/>
<source>Remove</source>
<translation>Удалить</translation>
<source>Remove account</source>
<translation>Удалить аккаунт</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="99"/>
<source>Account options Menu</source>
<translation>Меню настроек аккаунта</translation>
<source>Account options menu</source>
<translation>Меню аккаунта</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="332"/>
@@ -291,8 +291,8 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="355"/>
<source>Remove Space</source>
<translation>Удалить Пространство</translation>
<source>Remove folder sync</source>
<translation>Удалить синхронизацию папки</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="362"/>
@@ -301,13 +301,13 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="374"/>
<source>Add Space</source>
<translation>Добавить Пространство</translation>
<source>Add folder sync</source>
<translation>Добавить синхронизацию папки</translation>
</message>
<message>
<location filename="../src/gui/qml/FolderDelegate.qml" line="394"/>
<source>You are synchronizing %1 out of %2 Spaces</source>
<translation>Вы синхронизируете %1 из %2 Пространств</translation>
<source>Synchronized folders: %1 of %2</source>
<translation>Синхронизированные папки: %1 из %2</translation>
</message>
</context>
<context>
@@ -602,13 +602,13 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="58"/>
<source>Preparing the account</source>
<translation>Подготовка профиля</translation>
<source>Preparing account</source>
<translation>Подготовка аккаунта</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.ui" line="86"/>
<source>Sync connections</source>
<translation>Подключения для синхронизации</translation>
<source>Synchronized folders</source>
<translation>Синхронизированные папки</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="204"/>
@@ -623,18 +623,18 @@ File synchronization desktop utility.</source>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="197"/>
<source>Confirm removal of Space</source>
<translation>Подтвердите удаление Пространства</translation>
<source>Confirm removal of folder sync</source>
<translation>Подтвердите удаление синхронизации папки</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="198"/>
<source>&lt;p&gt;Do you really want to stop syncing the Space »%1«?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will &lt;b&gt;not&lt;/b&gt; delete any files.&lt;/p&gt;</source>
<translation>&lt;p&gt;Вы действительно хотите остановить синхронизацию Пространства »%1«? Примечание:&lt;/b&gt; Это &lt;b&gt;не&lt;/b&gt; приведет к удалению файлов.&lt;/p&gt;</translation>
<source>&lt;p&gt;Stop syncing &lt;b&gt;%1&lt;/b&gt; on this computer?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Note:&lt;/b&gt; This will not delete files in QSfera or on this device.&lt;/p&gt;</source>
<translation>&lt;p&gt;Остановить синхронизацию &lt;b&gt;%1&lt;/b&gt; на этом компьютере?&lt;/p&gt;&lt;p&gt;&lt;b&gt;Важно:&lt;/b&gt; Файлы в QSfera и на этом устройстве не будут удалены.&lt;/p&gt;</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="203"/>
<source>Remove Space</source>
<translation>Удалить Пространство</translation>
<source>Remove folder sync</source>
<translation>Удалить синхронизацию папки</translation>
</message>
<message>
<location filename="../src/gui/accountsettings.cpp" line="240"/>
@@ -1254,8 +1254,8 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="80"/>
<source>Start on Login</source>
<translation>Запускать при входе в систему</translation>
<source>Start QSfera when I sign in</source>
<translation>Запускать QSfera при входе в систему</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="52"/>
@@ -1269,28 +1269,28 @@ Please consider removing this folder from the account and adding it again.</sour
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="98"/>
<source>Sync hidden files</source>
<translation>Синхронизировать скрытые файлы</translation>
<source>Sync hidden files and folders</source>
<translation>Синхронизировать скрытые файлы и папки</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="111"/>
<source>Show crash reporter</source>
<translation>Показать отчет об ошибке</translation>
<source>Show crash reporter after a crash</source>
<translation>Показывать отчет о сбое после ошибки</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="118"/>
<source>Move remotely deleted files to the local trash bin instead of deleting them</source>
<translation>Перемещать файлы, удаленные через серверный доступ, в локальную корзину вместо их полного удаления</translation>
<source>Move server-deleted files to the local trash instead of deleting them</source>
<translation>Перемещать удаленные на сервере файлы в локальную корзину вместо удаления</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="127"/>
<source>Edit Ignored Files</source>
<translation>Изменить игнорируемые файлф</translation>
<source>Ignored files</source>
<translation>Игнорируемые файлы</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="134"/>
<source>Log Settings</source>
<translation>Настройки Логирования</translation>
<source>Logs</source>
<translation>Логи</translation>
</message>
<message>
<location filename="../src/gui/generalsettings.ui" line="161"/>
@@ -3457,4 +3457,4 @@ Note that using any logging command line options will override the settings.</so
<translation>Некоторые параметры были установлены в более новой версии клиента и используют функции, не доступные в текущей версии</translation>
</message>
</context>
</TS>
</TS>
+90
View File
@@ -0,0 +1,90 @@
# 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.10` with `versionCode` `38`.
- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and Argus upload.
- Android `1.3.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 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.
- 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.
- 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.
- Added `scripts/verify-production-state.ps1` as a repeatable live production endpoint gate for the public server status, Android Argus manifest/download, and Desktop update feed.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-10: server `edition=stable`/`productversion=7.0.0`, Android `1.3.10`/`38`, downloaded APK size `12228464`, APK SHA-256 `ce33a73ce122de6c9c797e7cb552e5493b628c60ddc98fda6a9e230e3c1ed7fe`, 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 `8465ba6d-a008-45f5-9c70-e6cd2c94317a`, version `1.3.10`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `38`, package size `12228464`, and SHA-256 `ce33a73ce122de6c9c797e7cb552e5493b628c60ddc98fda6a9e230e3c1ed7fe`.
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12228464` byte APK with the same SHA-256 `ce33a73ce122de6c9c797e7cb552e5493b628c60ddc98fda6a9e230e3c1ed7fe`.
- The previously published `1.3.5`/`33` APK matched Argus SHA-256 `b7d5bdcb49527c00983a15849252fa42189dee4287754803c46cf2f32e2130f3`, but `apksigner` reported `CN=Android Debug` with certificate SHA-256 `b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941`.
- The new `1.3.10`/`38` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
- `AppUpdateManager` validates downloaded APK package name, greater `versionCode`, Argus `androidVersionCode`, and signer digest against the installed app signature. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the production-signed `1.3.10`.
- 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.10` cannot be signed with the same certificate.
- Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
- Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values.
- Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified.
- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference. The current local MSI is intentionally not published as a production update because `Get-AuthenticodeSignature` reports `NotSigned` and `Desktop/scripts/validate-production-release.ps1 -RequireSigning` fails until signing is configured.
- Publish the first signed Windows MSI with `scripts/publish-windows-desktop.ps1 -RemoteHost ...` and update `https://qsfera.kusoft.xyz/desktop/updates/stable.xml` from the current no-update XML to a concrete `<version>`, `<versionstring>`, and HTTPS `.msi` `<downloadurl>`.
## Remaining P1
- Add Google Photos-style backup status UX: overall status, per-item status, queued/paused/permanent-failure states, and low-storage messaging.
- Store auto-upload sync cursors per source folder rather than one timestamp for all folders.
- Configure `QSFERA_ALERT_WEBHOOK_URL` in `/etc/qsfera/rpi-production-check.env` if off-host alert delivery is required; the installed failure service currently logs through `systemd-cat` and journal without an external endpoint.
- Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance.
+16
View File
@@ -0,0 +1,16 @@
QSFERA_RPI_IMAGE=qsfera-cloud-server:7.0.0-rpi5-stable
QSFERA_VERSION=7.0.0
QSFERA_EDITION=stable
QSFERA_BUILD_DATE=20260608
QSFERA_RPI_HOST_PORT=9200
QSFERA_RPI_DEBUG_HOST_PORT=9205
QSFERA_RPI_CONFIG_PATH=/mnt/data/qsfera/cloud-server/config
QSFERA_RPI_DATA_PATH=/mnt/data/qsfera/cloud-server/data
OC_URL=https://qsfera.example.com
OC_LOG_LEVEL=info
OC_INSECURE=false
PROXY_TLS=true
PROXY_DEBUG_ADDR=0.0.0.0:9205
PROXY_DEBUG_TOKEN=
FRONTEND_CHECK_FOR_UPDATES=false
+2 -1
View File
@@ -61,8 +61,9 @@ protogen/buf.sha1.lock
go.work
go.work.sum
.env
.env.server
.envrc
.DS_Store
# example deployments
**/qsfera-sandbox-*
**/qsfera-sandbox-*
+11 -2
View File
@@ -26,15 +26,21 @@ RUN make node-generate-prod
FROM golang:1.25-alpine AS build
ARG TARGETARCH=arm64
ARG QSFERA_VERSION=7.0.0
ARG QSFERA_EDITION=stable
ARG QSFERA_BUILD_DATE=20260608
RUN apk add bash make git curl gcc musl-dev libc-dev binutils-gold inotify-tools vips-dev
COPY --from=generate /qsfera /qsfera
WORKDIR /qsfera/qsfera
RUN make release-linux-docker-${TARGETARCH} ENABLE_VIPS=true DIST=/dist
RUN make release-linux-docker-${TARGETARCH} ENABLE_VIPS=true DIST=/dist VERSION="${QSFERA_VERSION}" EDITION="${QSFERA_EDITION}" DATE="${QSFERA_BUILD_DATE}"
FROM alpine:3.23
ARG TARGETARCH=arm64
ARG QSFERA_VERSION=7.0.0
ARG QSFERA_EDITION=stable
ARG QSFERA_BUILD_DATE=20260608
RUN apk add --no-cache attr ca-certificates curl mailcap tree vips && \
echo 'hosts: files dns' >| /etc/nsswitch.conf
@@ -46,7 +52,10 @@ LABEL maintainer="QSfera" \
org.opencontainers.image.description="КуСфера file-sync and share platform" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.documentation="" \
org.opencontainers.image.source=""
org.opencontainers.image.source="" \
org.opencontainers.image.version="${QSFERA_VERSION}" \
eu.qsfera.image.edition="${QSFERA_EDITION}" \
eu.qsfera.image.build-date="${QSFERA_BUILD_DATE}"
ENTRYPOINT ["/usr/bin/qsfera"]
CMD ["server"]
@@ -15,4 +15,6 @@ This deployment example is documented in two locations for different audiences:
Providing details which are more developer focused. This description can also be used when deviating from the default.\
Note that this examples uses self signed certificates and is intended for testing purposes.
For production rollouts from this compose directory, use `env.production.example` as the starting point for `.env`.
It keeps TLS validation enabled, disables demo users, and requires explicit admin, Keycloak, and S3 secrets instead of demo defaults.
Run `./validate-production-env.sh .env` before `docker compose config` and rollout.
@@ -9,6 +9,6 @@ services:
# decomposeds3 specific settings
STORAGE_USERS_DECOMPOSEDS3_ENDPOINT: ${DECOMPOSEDS3_ENDPOINT:-http://minio:9000}
STORAGE_USERS_DECOMPOSEDS3_REGION: ${DECOMPOSEDS3_REGION:-default}
STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key}
STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:-qsfera-bucket}
STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting decomposeds3 in production}
STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting decomposeds3 in production}
STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting decomposeds3 in production}
@@ -0,0 +1,49 @@
## Production-safe qsfera_full environment template.
## Copy to .env and fill every placeholder before running docker compose.
COMPOSE_PATH_SEPARATOR=:
QSFERA=:qsfera.yml
## Public deployment identity.
OC_DOMAIN=<cloud.example.com>
TRAEFIK_ACME_MAIL=<admin@example.com>
## Use the production image and pin a release tag for repeatable rollouts.
OC_DOCKER_IMAGE=qsfera/qsfera
OC_DOCKER_TAG=<release-tag>
## Production must validate TLS certificates and must not create demo users.
INSECURE=false
DEMO_USERS=false
## Required by qsfera.yml.
ADMIN_PASSWORD=<replace-with-strong-password>
## Optional: enable Keycloak-backed production identity.
## Uncomment KEYCLOAK and fill all values before enabling it.
#KEYCLOAK=:keycloak.yml
KEYCLOAK_DOMAIN=<keycloak.example.com>
KEYCLOAK_REALM=qsfera
KEYCLOAK_POSTGRES_PASSWORD=<replace-with-strong-password>
KEYCLOAK_ADMIN_USER=<replace-with-admin-user>
KEYCLOAK_ADMIN_PASSWORD=<replace-with-strong-password>
## Optional: enable S3-backed user storage.
## Uncomment DECOMPOSEDS3 and fill all values before enabling it.
#DECOMPOSEDS3=:decomposeds3.yml
DECOMPOSEDS3_ENDPOINT=<https://s3.example.com>
DECOMPOSEDS3_REGION=<region>
DECOMPOSEDS3_ACCESS_KEY=<replace-with-access-key>
DECOMPOSEDS3_SECRET_KEY=<replace-with-secret-key>
DECOMPOSEDS3_BUCKET=<bucket>
## Optional: local MinIO is intended for test/lab installs, not public production.
#DECOMPOSEDS3_MINIO=:minio.yml
#MINIO_DOMAIN=<minio.example.com>
## Optional: use host paths for backup-friendly config and data storage.
#OC_CONFIG_DIR=/srv/qsfera/config
#OC_DATA_DIR=/srv/qsfera/data
## Keep this last: it assembles the selected compose files.
COMPOSE_FILE=docker-compose.yml${QSFERA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${KEYCLOAK:-}
@@ -33,7 +33,7 @@ services:
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
POSTGRES_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production}
logging:
driver: ${LOG_DRIVER:-local}
restart: always
@@ -53,10 +53,10 @@ services:
KC_DB: postgres
KC_DB_URL: "jdbc:postgresql://postgres:5432/keycloak"
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_DB_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production}
KC_FEATURES: impersonation
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:?Set KEYCLOAK_ADMIN_USER before starting Keycloak in production}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD before starting Keycloak in production}
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.entrypoints=https"
@@ -10,13 +10,13 @@ services:
command:
[
"-c",
"mkdir -p /data/${DECOMPOSEDS3_BUCKET:-qsfera-bucket} && minio server --console-address ':9001' /data",
"mkdir -p /data/${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting MinIO in production} && minio server --console-address ':9001' /data",
]
volumes:
- minio-data:/data
environment:
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key}
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting MinIO in production}
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting MinIO in production}
labels:
- "traefik.enable=true"
- "traefik.http.routers.minio.entrypoints=https"
@@ -33,7 +33,7 @@ services:
# basic auth (not recommended, but needed for eg. WebDav clients that do not support OpenID Connect)
PROXY_ENABLE_BASIC_AUTH: "${PROXY_ENABLE_BASIC_AUTH:-false}"
# admin user password
IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:-admin}" # this overrides the admin password from the configuration file
IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:?Set ADMIN_PASSWORD before starting qsfera_full in production}" # this overrides the admin password from the configuration file
# demo users
IDM_CREATE_DEMO_USERS: "${DEMO_USERS:-false}"
# idp login form settings
@@ -0,0 +1,155 @@
#!/usr/bin/env sh
set -eu
ENV_FILE="${1:-.env}"
errors=0
warnings=0
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: env file not found: $ENV_FILE" >&2
exit 1
fi
value() {
key="$1"
awk -v key="$key" '
/^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
{
line = $0
sub(/\r$/, "", line)
split(line, parts, "=")
current = parts[1]
gsub(/^[ \t]+|[ \t]+$/, "", current)
if (current == key) {
sub(/^[^=]*=/, "", line)
gsub(/^"/, "", line)
gsub(/"$/, "", line)
gsub(/^'\''/, "", line)
gsub(/'\''$/, "", line)
print line
exit
}
}
' "$ENV_FILE"
}
is_placeholder() {
case "${1:-}" in
""|"<"*">"|*example.com*|"change-me"|"changeme"|"password"|"secret")
return 0
;;
*)
return 1
;;
esac
}
error() {
errors=$((errors + 1))
echo "ERROR: $1" >&2
}
warn() {
warnings=$((warnings + 1))
echo "WARN: $1" >&2
}
require_value() {
key="$1"
label="${2:-$1}"
current="$(value "$key")"
if is_placeholder "$current"; then
error "$label must be set to a production value."
fi
}
require_secret() {
key="$1"
current="$(value "$key")"
if is_placeholder "$current"; then
error "$key must be set."
return
fi
case "$current" in
admin|demo|keycloak|qsfera|qsfera-secret-key)
error "$key uses a known demo/default value."
return
;;
esac
if [ "${#current}" -lt 16 ]; then
error "$key must be at least 16 characters."
fi
}
enabled() {
current="$(value "$1")"
[ -n "$current" ]
}
require_value OC_DOMAIN "OC_DOMAIN"
require_value TRAEFIK_ACME_MAIL "TRAEFIK_ACME_MAIL"
require_secret ADMIN_PASSWORD
case "$(value INSECURE)" in
true|TRUE|1|yes|YES)
error "INSECURE must be false for production."
;;
esac
case "$(value DEMO_USERS)" in
true|TRUE|1|yes|YES)
error "DEMO_USERS must be false for production."
;;
esac
case "$(value OC_DOCKER_IMAGE)" in
qsfera/qsfera)
;;
"")
warn "OC_DOCKER_IMAGE is empty; compose default must be verified before rollout."
;;
*)
error "OC_DOCKER_IMAGE must be qsfera/qsfera for production."
;;
esac
case "$(value OC_DOCKER_TAG)" in
""|latest|"<"*">")
error "OC_DOCKER_TAG must pin a concrete production release tag."
;;
esac
if ! enabled QSFERA; then
error "QSFERA=:qsfera.yml must be enabled."
fi
if enabled DECOMPOSEDS3; then
require_value DECOMPOSEDS3_ENDPOINT "DECOMPOSEDS3_ENDPOINT"
require_value DECOMPOSEDS3_REGION "DECOMPOSEDS3_REGION"
require_secret DECOMPOSEDS3_ACCESS_KEY
require_secret DECOMPOSEDS3_SECRET_KEY
require_value DECOMPOSEDS3_BUCKET "DECOMPOSEDS3_BUCKET"
fi
if enabled DECOMPOSEDS3_MINIO; then
error "DECOMPOSEDS3_MINIO is for test/lab installs and must not be enabled for public production."
fi
if enabled KEYCLOAK; then
require_value KEYCLOAK_DOMAIN "KEYCLOAK_DOMAIN"
require_value KEYCLOAK_REALM "KEYCLOAK_REALM"
require_secret KEYCLOAK_POSTGRES_PASSWORD
require_value KEYCLOAK_ADMIN_USER "KEYCLOAK_ADMIN_USER"
require_secret KEYCLOAK_ADMIN_PASSWORD
fi
if [ -z "$(value OC_CONFIG_DIR)" ] || [ -z "$(value OC_DATA_DIR)" ]; then
warn "OC_CONFIG_DIR and OC_DATA_DIR are not both set; docker volumes are harder to back up and restore."
fi
if [ "$errors" -gt 0 ]; then
echo "Production env validation failed: $errors error(s), $warnings warning(s)." >&2
exit 1
fi
echo "Production env validation passed: $warnings warning(s)."
+41
View File
@@ -0,0 +1,41 @@
services:
qsfera-cloud-server:
container_name: qsfera-cloud-server
image: ${QSFERA_RPI_IMAGE:-qsfera-cloud-server:7.0.0-rpi5-stable}
platform: linux/arm64
build:
context: .
dockerfile: Dockerfile
args:
TARGETARCH: arm64
QSFERA_VERSION: ${QSFERA_VERSION:-7.0.0}
QSFERA_EDITION: ${QSFERA_EDITION:-stable}
QSFERA_BUILD_DATE: ${QSFERA_BUILD_DATE:-20260608}
restart: unless-stopped
entrypoint:
- /bin/sh
command:
- -c
- qsfera init || true; exec qsfera server
environment:
OC_LOG_LEVEL: ${OC_LOG_LEVEL:-info}
FRONTEND_CHECK_FOR_UPDATES: ${FRONTEND_CHECK_FOR_UPDATES:-false}
OC_CONFIG_DIR: /etc/qsfera
OC_BASE_DATA_PATH: /var/lib/qsfera
OC_URL: ${OC_URL:?OC_URL must be set in .env}
OC_INSECURE: ${OC_INSECURE:-false}
PROXY_TLS: ${PROXY_TLS:-true}
PROXY_DEBUG_ADDR: ${PROXY_DEBUG_ADDR:-0.0.0.0:9205}
PROXY_DEBUG_TOKEN: ${PROXY_DEBUG_TOKEN:-}
ports:
- "127.0.0.1:${QSFERA_RPI_HOST_PORT:-9200}:9200"
- "127.0.0.1:${QSFERA_RPI_DEBUG_HOST_PORT:-9205}:9205"
volumes:
- ${QSFERA_RPI_CONFIG_PATH:?QSFERA_RPI_CONFIG_PATH must be set in .env}:/etc/qsfera
- ${QSFERA_RPI_DATA_PATH:?QSFERA_RPI_DATA_PATH must be set in .env}:/var/lib/qsfera
healthcheck:
test: ["CMD-SHELL", "curl -kfsS https://127.0.0.1:9200/status.php >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
+292
View File
@@ -0,0 +1,292 @@
# Raspberry Pi 5 Docker Run
This server repository contains a Dockerized QSfera path for Raspberry Pi 5 and other
64-bit ARM Linux hosts.
Files used for this deployment:
- `Dockerfile`
- `docker-compose.rpi5.yml`
- `.env.example`
## What This Runs
- QSfera server from this `Server` source tree.
- A pinned production image tag by default: `qsfera-cloud-server:7.0.0-rpi5-stable`.
- Build metadata injected into the binary through Docker build args:
`QSFERA_VERSION`, `QSFERA_EDITION`, and `QSFERA_BUILD_DATE`.
- QSfera config persisted under `/etc/qsfera` inside the container.
- QSfera data persisted under `/var/lib/qsfera` inside the container.
- Host-side config and data paths controlled by `QSFERA_RPI_CONFIG_PATH` and
`QSFERA_RPI_DATA_PATH`.
- Host-side backend port bound only to `127.0.0.1:9200`; external access should go
through the reverse proxy and HTTPS domain.
- Host-side proxy debug/metrics port bound only to `127.0.0.1:9205`.
## Prerequisites On Raspberry Pi 5
1. Install Docker Engine using the official Docker docs: `https://docs.docker.com/engine/install/debian/`.
2. Install the Docker Compose plugin using the official Docker docs: `https://docs.docker.com/compose/install/`.
3. Use a 64-bit OS. The compose file sets `platform: linux/arm64`, and the Dockerfile builds with `TARGETARCH=arm64`.
4. Put `QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` on durable storage, not on a nearly full SD-card root filesystem.
5. Set `OC_URL` to the public HTTPS URL used by clients and the reverse proxy.
6. Keep `QSFERA_EDITION=stable` and a concrete `QSFERA_VERSION` for production
rollouts. Leaving those unset falls back to the defaults in the compose file.
7. Keep Docker image and builder retention under control. The current Raspberry Pi
deployment stores QSfera data on `/mnt/data`; Docker's own `data-root` must
either be moved there during a maintenance window or pruned on a schedule.
## Start
Run from the `Server` directory:
```bash
cp .env.example .env
docker compose -f docker-compose.rpi5.yml up -d --build
```
Verify the image metadata before rollout:
```bash
docker compose -f docker-compose.rpi5.yml build
docker run --rm --entrypoint /usr/bin/qsfera qsfera-cloud-server:7.0.0-rpi5-stable version --skip-services
```
Run the production checker from the `Server` directory after every rollout:
```bash
PUBLIC_STATUS_URL=https://qsfera.example.com/status.php \
./scripts/verify-rpi-production.sh
```
## Stop
```bash
docker compose -f docker-compose.rpi5.yml down
```
## Logs
```bash
docker compose -f docker-compose.rpi5.yml logs -f qsfera-cloud-server
```
## Update After Pulling New Code
```bash
docker compose -f docker-compose.rpi5.yml up -d --build
```
## Verify
Local backend status endpoint:
```bash
curl -k https://127.0.0.1:9200/status.php
```
External reverse-proxied status endpoint:
```bash
curl https://qsfera.example.com/status.php
```
Local Prometheus metrics endpoint:
```bash
curl http://127.0.0.1:9205/metrics
```
Repeatable production gate from the `Server` directory:
```bash
PUBLIC_STATUS_URL=https://qsfera.example.com/status.php \
REQUIRE_METRICS=true \
./scripts/verify-rpi-production.sh
```
The checker validates:
- Docker Compose config rendering.
- Local presence of the pinned image.
- Image metadata from `qsfera version --skip-services`.
- Container running state, Docker health status, and container image tag.
- Docker root location, root filesystem free space, and data filesystem free space.
- Local and optional public `/status.php` JSON invariants:
`installed=true`, `maintenance=false`, `needsDbUpgrade=false`,
`edition=stable`, and `productversion=7.0.0`.
- Optional Prometheus metrics reachability.
The default free-space thresholds are 5 GiB for `/` and 20 GiB for `/mnt/data`.
Override them with `MIN_ROOT_AVAILABLE_KB` and `MIN_DATA_AVAILABLE_KB` when the
host has a documented capacity plan.
## Data Location
Docker bind mounts:
- host config: `${QSFERA_RPI_CONFIG_PATH}`
- container config: `/etc/qsfera`
- host data: `${QSFERA_RPI_DATA_PATH}`
- container data: `/var/lib/qsfera`
For the current Raspberry Pi deployment checked on 2026-06-09 after the metrics
rollout, the existing container uses:
- host config: `/mnt/data/qsfera/cloud-server/config`
- host data: `/mnt/data/qsfera/cloud-server/data`
- image: `qsfera-cloud-server:7.0.0-rpi5-stable`
- public URL: `https://qsfera.kusoft.xyz`
- loopback port: `127.0.0.1:9200`
- loopback metrics port: `127.0.0.1:9205`
- Docker data-root: `/mnt/data/docker`
## Reverse Proxy
The server is intentionally bound to loopback. Put Caddy, Nginx, Traefik, or another
reverse proxy in front of it for external HTTPS access.
The inspected Raspberry Pi uses Caddy with this QSfera route:
```caddyfile
qsfera.kusoft.xyz {
encode zstd gzip
handle {
reverse_proxy https://127.0.0.1:9200 {
transport http {
tls_insecure_skip_verify
versions 1.1
}
}
}
}
```
Do not proxy `127.0.0.1:9205` to the public Internet. If a remote Prometheus
server needs to scrape it, connect through a private tunnel or bind it to an
internal-only interface and set `PROXY_DEBUG_TOKEN`.
## Monitoring And Alerts
Install the production checker timer from the `Server` directory:
```bash
sudo ./scripts/install-rpi-production-monitoring.sh
```
The installer writes:
- `/etc/systemd/system/qsfera-rpi-production-check.service`
- `/etc/systemd/system/qsfera-rpi-production-check.timer`
- `/etc/systemd/system/qsfera-rpi-production-check-alert@.service`
- `/etc/qsfera/rpi-production-check.env`
The timer runs `./scripts/verify-rpi-production.sh` every 5 minutes. The service
uses `OnFailure=qsfera-rpi-production-check-alert@%n.service`; the alert service
logs the failed unit and recent journal output through `systemd-cat`. To forward
alerts off-host, set `QSFERA_ALERT_WEBHOOK_URL` in
`/etc/qsfera/rpi-production-check.env`; `alert-rpi-production-check-failure.sh`
sends a JSON `POST` when that variable is set.
For the current Raspberry Pi deployment checked on 2026-06-09, the timer was
installed under `/etc/systemd/system`, `systemctl is-enabled` returned
`enabled`, `systemctl is-active` returned `active`, and the checker service
reported `Result=success`, `ExecMainStatus=0`, and `ActiveState=inactive` after
the initial run. `systemctl list-timers qsfera-rpi-production-check.timer`
reported the next run 5 minutes after the previous run.
Verify the timer:
```bash
systemctl list-timers qsfera-rpi-production-check.timer --no-pager
systemctl status qsfera-rpi-production-check.timer --no-pager
systemctl show qsfera-rpi-production-check.service -p Result -p ExecMainStatus -p ActiveState
journalctl -u qsfera-rpi-production-check.service -n 80 --no-pager
```
Minimum monitoring expectations:
- Keep `qsfera-rpi-production-check.timer` enabled and active.
- Investigate any non-zero `ExecMainStatus` from
`qsfera-rpi-production-check.service`.
- Investigate any `WARN:` line that persists for more than one day, especially
Docker root outside `/mnt/data` or unavailable metrics.
- Configure `QSFERA_ALERT_WEBHOOK_URL` when off-host alert delivery is required;
without it, failures are still routed to the systemd failure service and
journal, but not to an external notification endpoint.
- Scrape `http://127.0.0.1:9205/metrics` locally or through a private tunnel.
- Keep public HTTPS probing separate from local loopback probing so reverse-proxy
and backend failures are distinguishable.
- Keep `OC_LOG_LEVEL=info` for production; raise to `debug` only for a bounded
incident window.
For larger deployments, the full compose example already contains tracing and
metrics wiring in `devtools/deployments/qsfera_full/monitoring_tracing/`.
## Backups And Restore Drill
Back up the two bind-mounted paths from `.env`:
- `QSFERA_RPI_CONFIG_PATH`
- `QSFERA_RPI_DATA_PATH`
Create an archive before every server image rollout:
```bash
stamp="$(date -u +%Y%m%d-%H%M%S)"
backup="/mnt/data/qsfera/backups/cloud-server-${stamp}.tar.gz"
sudo tar -C /mnt/data/qsfera/cloud-server -czf "$backup" config data
sudo tar -tzf "$backup" >/dev/null
```
Run a restore drill after backup automation changes and at least monthly:
```bash
stamp="$(date -u +%Y%m%d-%H%M%S)"
restore_root="/mnt/data/qsfera/restore-drills/${stamp}"
sudo mkdir -p "$restore_root"
sudo tar -C "$restore_root" -xzf "$backup"
sudo test -d "$restore_root/config"
sudo test -d "$restore_root/data"
```
For a full service restore test, copy `.env` to a drill-only file, point
`QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` at the extracted directories,
bind `QSFERA_RPI_HOST_PORT` to an unused loopback port, run
`docker compose -f docker-compose.rpi5.yml --env-file .env.restore config`, and
only then start the drill container. Do not run a restore drill against the live
config/data paths.
## Docker Data-Root And Retention
Check where Docker stores images, layers, and build cache:
```bash
docker info --format '{{.DockerRootDir}}'
docker system df
```
Preferred production state on this Pi is Docker data-root under `/mnt/data`,
because the QSfera bind-mounted data is already stored there. Move it only during
a maintenance window:
```bash
sudo systemctl stop docker
sudo mkdir -p /mnt/data/docker
sudo rsync -aHAX --numeric-ids /var/lib/docker/ /mnt/data/docker/
printf '{\n "data-root": "/mnt/data/docker"\n}\n' | sudo tee /etc/docker/daemon.json
sudo systemctl start docker
docker info --format '{{.DockerRootDir}}'
docker compose -f docker-compose.rpi5.yml ps
```
If moving Docker data-root is postponed, keep conservative retention active:
```bash
docker builder prune --filter 'until=168h'
docker image prune --filter 'until=168h'
docker system df
```
Do not run `docker system prune --volumes` on this host unless a current backup
has been verified and the target volumes have been explicitly reviewed.
@@ -0,0 +1,56 @@
#!/usr/bin/env sh
set -eu
FAILED_UNIT="${1:-qsfera-rpi-production-check.service}"
JOURNAL_LINES="${QSFERA_ALERT_JOURNAL_LINES:-80}"
HOSTNAME_VALUE="$(hostname -f 2>/dev/null || hostname)"
TIMESTAMP_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
SUMMARY="QSfera Raspberry Pi production check failed on ${HOSTNAME_VALUE}: ${FAILED_UNIT} at ${TIMESTAMP_UTC}"
if command -v journalctl >/dev/null 2>&1; then
JOURNAL_OUTPUT="$(journalctl -u "$FAILED_UNIT" -n "$JOURNAL_LINES" --no-pager 2>/dev/null || true)"
else
JOURNAL_OUTPUT=""
fi
{
echo "$SUMMARY"
if [ -n "$JOURNAL_OUTPUT" ]; then
echo
echo "$JOURNAL_OUTPUT"
fi
} | if command -v systemd-cat >/dev/null 2>&1; then
systemd-cat -t qsfera-rpi-production-alert -p err
else
cat >&2
fi
if [ -n "${QSFERA_ALERT_WEBHOOK_URL:-}" ]; then
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 is required to format the alert webhook payload." >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "ERROR: curl is required to send the alert webhook." >&2
exit 1
fi
python3 - "$SUMMARY" "$FAILED_UNIT" "$HOSTNAME_VALUE" "$TIMESTAMP_UTC" "$JOURNAL_OUTPUT" <<'PY' |
import json
import sys
summary, failed_unit, hostname, timestamp_utc, journal = sys.argv[1:]
print(json.dumps({
"summary": summary,
"failedUnit": failed_unit,
"host": hostname,
"timestampUtc": timestamp_utc,
"journal": journal,
}, ensure_ascii=False))
PY
curl -fsS \
-X POST \
-H "Content-Type: application/json" \
--data-binary @- \
"$QSFERA_ALERT_WEBHOOK_URL" >/dev/null
fi
@@ -0,0 +1,62 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
SERVER_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
ENV_DIR="${ENV_DIR:-/etc/qsfera}"
ENV_FILE="${ENV_FILE:-$ENV_DIR/rpi-production-check.env}"
RUN_NOW="${RUN_NOW:-true}"
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
else
SUDO="${SUDO:-sudo}"
fi
run_privileged() {
if [ -n "$SUDO" ]; then
"$SUDO" "$@"
else
"$@"
fi
}
install_unit() {
source_path="$1"
target_path="$2"
run_privileged install -m 0644 "$source_path" "$target_path"
}
tmp_env="$(mktemp)"
cleanup() {
rm -f "$tmp_env"
}
trap cleanup EXIT INT TERM
run_privileged install -d -m 0755 "$SYSTEMD_DIR" "$ENV_DIR"
if [ ! -f "$ENV_FILE" ]; then
{
echo "QSFERA_SERVER_DIR=$SERVER_DIR"
grep -v '^QSFERA_SERVER_DIR=' "$SERVER_DIR/systemd/rpi-production-check.env.example"
} >"$tmp_env"
run_privileged install -m 0640 "$tmp_env" "$ENV_FILE"
echo "Created $ENV_FILE"
else
echo "Keeping existing $ENV_FILE"
fi
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check.service" "$SYSTEMD_DIR/qsfera-rpi-production-check.service"
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check.timer" "$SYSTEMD_DIR/qsfera-rpi-production-check.timer"
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check-alert@.service" "$SYSTEMD_DIR/qsfera-rpi-production-check-alert@.service"
run_privileged systemctl daemon-reload
run_privileged systemctl enable --now qsfera-rpi-production-check.timer
if [ "$RUN_NOW" = "true" ]; then
run_privileged systemctl start qsfera-rpi-production-check.service
fi
run_privileged systemctl --no-pager status qsfera-rpi-production-check.timer
run_privileged systemctl --no-pager show qsfera-rpi-production-check.service -p Result -p ExecMainStatus -p ActiveState
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env sh
set -eu
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.rpi5.yml}"
ENV_FILE="${ENV_FILE:-.env}"
SERVICE="${SERVICE:-qsfera-cloud-server}"
CONTAINER="${CONTAINER:-qsfera-cloud-server}"
EXPECTED_IMAGE="${EXPECTED_IMAGE:-qsfera-cloud-server:7.0.0-rpi5-stable}"
EXPECTED_VERSION="${EXPECTED_VERSION:-7.0.0}"
EXPECTED_EDITION="${EXPECTED_EDITION:-stable}"
LOCAL_STATUS_URL="${LOCAL_STATUS_URL:-https://127.0.0.1:9200/status.php}"
PUBLIC_STATUS_URL="${PUBLIC_STATUS_URL:-}"
METRICS_URL="${METRICS_URL:-http://127.0.0.1:9205/metrics}"
REQUIRE_METRICS="${REQUIRE_METRICS:-false}"
VERIFY_IMAGE_VERSION="${VERIFY_IMAGE_VERSION:-true}"
ROOT_PATH="${ROOT_PATH:-/}"
DATA_PATH="${DATA_PATH:-/mnt/data}"
MIN_ROOT_AVAILABLE_KB="${MIN_ROOT_AVAILABLE_KB:-5242880}"
MIN_DATA_AVAILABLE_KB="${MIN_DATA_AVAILABLE_KB:-20971520}"
EXPECTED_DOCKER_ROOT_PREFIX="${EXPECTED_DOCKER_ROOT_PREFIX:-/mnt/data}"
STRICT_DOCKER_ROOT="${STRICT_DOCKER_ROOT:-false}"
errors=0
warnings=0
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT INT TERM
ok() {
echo "OK: $*"
}
warn() {
warnings=$((warnings + 1))
echo "WARN: $*" >&2
}
error() {
errors=$((errors + 1))
echo "ERROR: $*" >&2
}
is_true() {
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
true|1|yes|on)
return 0
;;
*)
return 1
;;
esac
}
require_command() {
if command -v "$1" >/dev/null 2>&1; then
ok "Command available: $1"
else
error "Required command is missing: $1"
fi
}
compose() {
if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
else
docker compose -f "$COMPOSE_FILE" "$@"
fi
}
available_kb() {
df -Pk "$1" 2>/dev/null | awk 'NR == 2 { print $4 }'
}
format_gib() {
awk -v kb="$1" 'BEGIN { printf "%.1f", kb / 1048576 }'
}
check_disk() {
path="$1"
minimum_kb="$2"
label="$3"
current_kb="$(available_kb "$path")"
if [ -z "$current_kb" ]; then
error "Cannot read free disk space for $label at $path."
return
fi
if [ "$current_kb" -ge "$minimum_kb" ]; then
ok "$label free space $(format_gib "$current_kb") GiB >= $(format_gib "$minimum_kb") GiB"
else
error "$label free space $(format_gib "$current_kb") GiB < $(format_gib "$minimum_kb") GiB"
fi
}
check_status_url() {
label="$1"
url="$2"
curl_opts="$3"
output="$tmp_dir/status.json"
if curl $curl_opts "$url" -o "$output"; then
ok "$label returned a successful HTTP response"
else
error "$label request failed: $url"
return
fi
if python3 - "$output" "$EXPECTED_EDITION" "$EXPECTED_VERSION" "$label" <<'PY'
import json
import sys
path, expected_edition, expected_version, label = sys.argv[1:]
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
checks = [
("installed", payload.get("installed") is True, True, payload.get("installed")),
("maintenance", payload.get("maintenance") is False, False, payload.get("maintenance")),
("needsDbUpgrade", payload.get("needsDbUpgrade") is False, False, payload.get("needsDbUpgrade")),
("edition", payload.get("edition") == expected_edition, expected_edition, payload.get("edition")),
("productversion", payload.get("productversion") == expected_version, expected_version, payload.get("productversion")),
]
failed = False
for name, passed, expected, actual in checks:
if passed:
print(f"OK: {label} {name} = {actual}")
else:
print(f"ERROR: {label} {name}: expected {expected!r}, got {actual!r}", file=sys.stderr)
failed = True
if failed:
sys.exit(1)
PY
then
ok "$label JSON invariants match expected production state"
else
error "$label JSON invariants failed"
fi
}
check_metrics() {
output="$tmp_dir/metrics.txt"
if [ -n "${METRICS_BEARER_TOKEN:-}" ]; then
metrics_result=0
curl -fsS -H "Authorization: Bearer ${METRICS_BEARER_TOKEN}" "$METRICS_URL" -o "$output" || metrics_result=$?
else
metrics_result=0
curl -fsS "$METRICS_URL" -o "$output" || metrics_result=$?
fi
if [ "$metrics_result" -eq 0 ]; then
if grep -Eq '^(# HELP|# TYPE|go_|process_)' "$output"; then
ok "Metrics endpoint returned Prometheus-formatted content"
else
error "Metrics endpoint responded, but content does not look like Prometheus metrics."
fi
return
fi
if is_true "$REQUIRE_METRICS"; then
error "Metrics endpoint is required but unavailable: $METRICS_URL"
else
warn "Metrics endpoint unavailable: $METRICS_URL"
fi
}
for command_name in docker curl python3 awk df; do
require_command "$command_name"
done
if [ ! -f "$COMPOSE_FILE" ]; then
error "Compose file not found: $COMPOSE_FILE"
fi
if [ -n "$ENV_FILE" ] && [ ! -f "$ENV_FILE" ]; then
error "Environment file not found: $ENV_FILE"
fi
if [ "$errors" -eq 0 ]; then
if compose config >/dev/null; then
ok "Docker Compose config renders successfully for $COMPOSE_FILE"
else
error "Docker Compose config failed for $COMPOSE_FILE"
fi
fi
if docker image inspect "$EXPECTED_IMAGE" >/dev/null 2>&1; then
ok "Expected image exists locally: $EXPECTED_IMAGE"
else
error "Expected image is missing locally: $EXPECTED_IMAGE"
fi
if is_true "$VERIFY_IMAGE_VERSION"; then
version_output="$tmp_dir/image-version.txt"
if docker run --rm --entrypoint /usr/bin/qsfera "$EXPECTED_IMAGE" version --skip-services >"$version_output" 2>&1; then
if grep -q "Version: $EXPECTED_VERSION" "$version_output" && grep -q "Edition: $EXPECTED_EDITION" "$version_output"; then
ok "Image version metadata matches $EXPECTED_EDITION $EXPECTED_VERSION"
else
error "Image version metadata does not match expected $EXPECTED_EDITION $EXPECTED_VERSION."
sed 's/^/ /' "$version_output" >&2
fi
else
error "Cannot read image version metadata from $EXPECTED_IMAGE."
sed 's/^/ /' "$version_output" >&2
fi
fi
container_status="$(docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>/dev/null || true)"
if [ "$container_status" = "running" ]; then
ok "Container $CONTAINER is running"
else
error "Container $CONTAINER status is '${container_status:-missing}', expected running."
fi
container_health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CONTAINER" 2>/dev/null || true)"
case "$container_health" in
healthy)
ok "Container $CONTAINER health is healthy"
;;
none)
warn "Container $CONTAINER has no Docker healthcheck result."
;;
*)
error "Container $CONTAINER health is '${container_health:-missing}', expected healthy."
;;
esac
container_image="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER" 2>/dev/null || true)"
if [ "$container_image" = "$EXPECTED_IMAGE" ]; then
ok "Container image is $EXPECTED_IMAGE"
else
error "Container image is '${container_image:-missing}', expected $EXPECTED_IMAGE."
fi
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
case "$docker_root" in
"$EXPECTED_DOCKER_ROOT_PREFIX"|"$EXPECTED_DOCKER_ROOT_PREFIX"/*)
ok "Docker root is under $EXPECTED_DOCKER_ROOT_PREFIX: $docker_root"
;;
"")
error "Cannot read Docker root directory."
;;
*)
if is_true "$STRICT_DOCKER_ROOT"; then
error "Docker root is $docker_root, expected path under $EXPECTED_DOCKER_ROOT_PREFIX."
else
warn "Docker root is $docker_root, not under $EXPECTED_DOCKER_ROOT_PREFIX; keep image/build retention active or move data-root during maintenance."
fi
;;
esac
check_disk "$ROOT_PATH" "$MIN_ROOT_AVAILABLE_KB" "Root filesystem"
check_disk "$DATA_PATH" "$MIN_DATA_AVAILABLE_KB" "Data filesystem"
check_status_url "Local server status" "$LOCAL_STATUS_URL" "-kfsS"
if [ -n "$PUBLIC_STATUS_URL" ]; then
check_status_url "Public server status" "$PUBLIC_STATUS_URL" "-fsS"
fi
check_metrics
if [ "$errors" -gt 0 ]; then
echo "QSfera Raspberry Pi production verification failed: $errors error(s), $warnings warning(s)." >&2
exit 1
fi
echo "QSfera Raspberry Pi production verification passed: $warnings warning(s)."
@@ -0,0 +1,8 @@
[Unit]
Description=Report QSfera Raspberry Pi production check failure for %I
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
[Service]
Type=oneshot
EnvironmentFile=-/etc/qsfera/rpi-production-check.env
ExecStart=/bin/sh -c 'cd "${QSFERA_SERVER_DIR:-/mnt/data/qsfera/cloud-server/src}" && exec ./scripts/alert-rpi-production-check-failure.sh "$1"' -- "%I"
@@ -0,0 +1,12 @@
[Unit]
Description=QSfera Raspberry Pi production verification
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
Wants=network-online.target
After=network-online.target docker.service
Requires=docker.service
OnFailure=qsfera-rpi-production-check-alert@%n.service
[Service]
Type=oneshot
EnvironmentFile=-/etc/qsfera/rpi-production-check.env
ExecStart=/bin/sh -c 'cd "${QSFERA_SERVER_DIR:-/mnt/data/qsfera/cloud-server/src}" && exec ./scripts/verify-rpi-production.sh'
@@ -0,0 +1,13 @@
[Unit]
Description=Run QSfera Raspberry Pi production verification every five minutes
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
Unit=qsfera-rpi-production-check.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,15 @@
QSFERA_SERVER_DIR=/mnt/data/qsfera/cloud-server/src
PUBLIC_STATUS_URL=https://qsfera.kusoft.xyz/status.php
REQUIRE_METRICS=true
METRICS_URL=http://127.0.0.1:9205/metrics
EXPECTED_IMAGE=qsfera-cloud-server:7.0.0-rpi5-stable
EXPECTED_VERSION=7.0.0
EXPECTED_EDITION=stable
EXPECTED_DOCKER_ROOT_PREFIX=/mnt/data
STRICT_DOCKER_ROOT=true
MIN_ROOT_AVAILABLE_KB=5242880
MIN_DATA_AVAILABLE_KB=20971520
QSFERA_ALERT_JOURNAL_LINES=80
# Optional off-host alert endpoint. The alert script sends a JSON POST when set.
#QSFERA_ALERT_WEBHOOK_URL=https://alerts.example.com/qsfera-rpi
+334
View File
@@ -0,0 +1,334 @@
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 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
Remove-PathWithRetry $zipPath
Compress-ArchiveWithRetry -SourcePath (Join-Path $StageDirectory "*") -DestinationPath $zipPath
if (-not $SkipMsi) {
Remove-PathWithRetry $msiPath
New-QSferaMsi -StageDirectory $StageDirectory -MsiPath $msiPath -Version $Version
}
$stageFiles = Get-ChildItem -LiteralPath $StageDirectory -Recurse -File
$stageBytes = ($stageFiles | Measure-Object Length -Sum).Sum
Write-Output "QSfera Windows desktop package prepared."
Write-Output "Version: $Version"
Write-Output "Stage: $StageDirectory"
Write-Output "Stage files: $($stageFiles.Count)"
Write-Output "Stage bytes: $stageBytes"
Write-Output "ZIP: $zipPath"
if (-not $SkipMsi) {
Write-Output "MSI: $msiPath"
}
+190
View File
@@ -0,0 +1,190 @@
param(
[string]$MsiPath = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi"),
[string]$Version,
[string]$Channel = "stable",
[string]$PublicBaseUrl = "https://qsfera.kusoft.xyz",
[string]$OutputRoot = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop\publish"),
[string]$RemoteHost,
[string]$RemoteUser = "sevenhill",
[int]$RemotePort = 22,
[string]$RemoteDownloadDirectory = "/var/www/qsfera/desktop/downloads",
[string]$RemoteFeedPath = "/var/www/qsfera/desktop/updates/stable.xml",
[switch]$SkipSignatureCheck
)
$ErrorActionPreference = "Stop"
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Assert-HttpsBaseUrl([string]$Url) {
$uri = $null
if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne "https") {
Fail "PublicBaseUrl must be an absolute HTTPS URL: $Url"
}
return $uri.AbsoluteUri.TrimEnd("/")
}
function Get-QSferaDesktopVersion {
$versionFile = Join-Path $PSScriptRoot "..\Desktop\VERSION.cmake"
if (-not (Test-Path -LiteralPath $versionFile)) {
Fail "Desktop version file was not found: $versionFile"
}
$values = @{}
foreach ($line in Get-Content -LiteralPath $versionFile) {
if ($line -match '^\s*set\(\s*(MIRALL_VERSION_(MAJOR|MINOR|PATCH|BUILD))\s+"?([^"\s\)]+)"?\s*\)') {
$values[$matches[1]] = $matches[3]
}
}
foreach ($key in @("MIRALL_VERSION_MAJOR", "MIRALL_VERSION_MINOR", "MIRALL_VERSION_PATCH")) {
if (-not $values.ContainsKey($key)) {
Fail "Desktop version file does not define $key."
}
}
$baseVersion = "{0}.{1}.{2}" -f $values["MIRALL_VERSION_MAJOR"], $values["MIRALL_VERSION_MINOR"], $values["MIRALL_VERSION_PATCH"]
$build = $values["MIRALL_VERSION_BUILD"]
if ([string]::IsNullOrWhiteSpace($build) -or $build -eq "0") {
return $baseVersion
}
return "$baseVersion.$build"
}
function Quote-RemotePath([string]$Path) {
return "'" + ($Path -replace "'", "'\''") + "'"
}
function Get-RemoteParentPath([string]$Path) {
$index = $Path.LastIndexOf("/")
if ($index -le 0) {
return "/"
}
return $Path.Substring(0, $index)
}
function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) {
& $Program @Arguments
if ($LASTEXITCODE -ne 0) {
Fail "$Program failed with exit code $LASTEXITCODE."
}
}
$resolvedMsi = $null
try {
$resolvedMsi = (Resolve-Path -LiteralPath $MsiPath).Path
} catch {
Fail "MSI file was not found: $MsiPath"
}
if (-not $resolvedMsi.EndsWith(".msi", [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "Windows desktop publication expects an .msi file: $resolvedMsi"
}
$hasRemoteTarget = -not [string]::IsNullOrWhiteSpace($RemoteHost)
if ($hasRemoteTarget -and $SkipSignatureCheck) {
Fail "Remote Windows desktop publication cannot use -SkipSignatureCheck."
}
$signature = Get-AuthenticodeSignature -LiteralPath $resolvedMsi
if (-not $SkipSignatureCheck -and $signature.Status -ne "Valid") {
Fail "MSI must be Authenticode-signed before Windows production publication. Actual status: $($signature.Status)."
}
if ([string]::IsNullOrWhiteSpace($Version)) {
$Version = Get-QSferaDesktopVersion
}
$parsedVersion = $null
if (-not [System.Version]::TryParse($Version, [ref]$parsedVersion)) {
Fail "Version must be parseable as a Windows updater version: $Version"
}
if ($Channel -ne "stable") {
Fail "Only the stable desktop update feed is configured for production publication. Actual channel: $Channel"
}
$publicBase = Assert-HttpsBaseUrl $PublicBaseUrl
$msiName = Split-Path -Leaf $resolvedMsi
$downloadUrl = "$publicBase/desktop/downloads/$msiName"
$feedPath = Join-Path (New-Item -ItemType Directory -Path $OutputRoot -Force).FullName "$Channel.xml"
$sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedMsi).Hash.ToLowerInvariant()
$size = (Get-Item -LiteralPath $resolvedMsi).Length
$feed = @"
<?xml version="1.0" encoding="UTF-8"?>
<QSfera>
<version>$Version</version>
<versionstring>$Version</versionstring>
<web>$publicBase/</web>
<downloadurl>$downloadUrl</downloadurl>
</QSfera>
"@
$feed | Set-Content -LiteralPath $feedPath -Encoding UTF8
$feedValidator = Join-Path $PSScriptRoot "..\Desktop\scripts\validate-desktop-update-feed.ps1"
if (-not (Test-Path -LiteralPath $feedValidator)) {
Fail "Desktop update feed validator was not found: $feedValidator"
}
& $feedValidator -Path $feedPath
if (-not $?) {
Fail "Generated desktop update feed failed validation."
}
Write-Output "QSfera Windows desktop publication prepared."
Write-Output "MSI: $resolvedMsi"
Write-Output "Version: $Version"
Write-Output "Size: $size"
Write-Output "SHA-256: $sha256"
Write-Output "Feed: $feedPath"
Write-Output "Download URL: $downloadUrl"
Write-Output "Signature: $($signature.Status)"
if (-not $hasRemoteTarget) {
Write-Output "Remote upload skipped: set -RemoteHost after signing the MSI."
exit 0
}
$scp = Get-Command scp -ErrorAction SilentlyContinue
$ssh = Get-Command ssh -ErrorAction SilentlyContinue
if (-not $scp -or -not $ssh) {
Fail "Both scp and ssh must be available for remote Windows desktop publication."
}
$remote = "$RemoteUser@$RemoteHost"
$remoteMsiTmp = "$RemoteDownloadDirectory/$msiName.tmp"
$remoteMsiFinal = "$RemoteDownloadDirectory/$msiName"
$remoteFeedTmp = "$RemoteFeedPath.tmp"
$sshPort = [string]$RemotePort
$remoteFeedDirectory = Get-RemoteParentPath $RemoteFeedPath
$prepareCommand = @(
"set -e",
"mkdir -p $(Quote-RemotePath $RemoteDownloadDirectory)",
"mkdir -p $(Quote-RemotePath $remoteFeedDirectory)"
) -join " && "
Invoke-CheckedNative $ssh.Source @("-p", $sshPort, $remote, $prepareCommand)
Invoke-CheckedNative $scp.Source @("-P", $sshPort, $resolvedMsi, "${remote}:$remoteMsiTmp")
Invoke-CheckedNative $scp.Source @("-P", $sshPort, $feedPath, "${remote}:$remoteFeedTmp")
$remoteCommand = @(
"set -e",
"test -s $(Quote-RemotePath $remoteMsiTmp)",
"test -s $(Quote-RemotePath $remoteFeedTmp)",
"mv -f $(Quote-RemotePath $remoteMsiTmp) $(Quote-RemotePath $remoteMsiFinal)",
"mv -f $(Quote-RemotePath $remoteFeedTmp) $(Quote-RemotePath $RemoteFeedPath)"
) -join " && "
Invoke-CheckedNative $ssh.Source @("-p", $sshPort, $remote, $remoteCommand)
& $feedValidator -Url "$publicBase/desktop/updates/$Channel.xml"
if (-not $?) {
Fail "Live desktop update feed failed validation after upload."
}
Write-Output "QSfera Windows desktop publication uploaded."
+69
View File
@@ -0,0 +1,69 @@
param(
[string]$RuntimeRoot = "$env:USERPROFILE\craft\CraftMaster\windows-cl-msvc2022-x86_64",
[string]$AppName = "QSfera",
[switch]$Launch
)
$ErrorActionPreference = "Stop"
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
$runtimeBin = Join-Path $RuntimeRoot "bin"
$runtimeExe = Join-Path $runtimeBin "qsfera.exe"
if (-not (Test-Path -LiteralPath $runtimeExe)) {
Fail "QSfera executable was not found: $runtimeExe"
}
$requiredDlls = @(
"Qt6Core.dll",
"Qt6Gui.dll",
"Qt6Widgets.dll",
"Qt6Network.dll",
"Qt6Qml.dll",
"Qt6Quick.dll",
"qt6keychain.dll",
"kdsingleapplication-qt6.dll"
)
foreach ($dll in $requiredDlls) {
$dllPath = Join-Path $runtimeBin $dll
if (-not (Test-Path -LiteralPath $dllPath)) {
Fail "Required runtime DLL is missing: $dllPath"
}
}
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
New-ItemProperty -Path $runKey -Name $AppName -PropertyType String -Value $runtimeExe -Force | Out-Null
$actualValue = (Get-ItemProperty -Path $runKey).$AppName
if ($actualValue -ne $runtimeExe) {
Fail "Autostart registry value was not updated. Expected '$runtimeExe', got '$actualValue'."
}
Write-Output "OK: $AppName autostart points to $runtimeExe"
Write-Output "OK: required Qt6 runtime DLLs are present in $runtimeBin"
if ($Launch) {
Get-Process -Name "qsfera" -ErrorAction SilentlyContinue |
Where-Object { $_.Path -ne $runtimeExe } |
Stop-Process -Force -ErrorAction SilentlyContinue
if (-not (Get-Process -Name "qsfera" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $runtimeExe })) {
Start-Process -FilePath $runtimeExe -WorkingDirectory $runtimeBin -WindowStyle Hidden
Start-Sleep -Seconds 5
}
$process = Get-Process -Name "qsfera" -ErrorAction SilentlyContinue |
Where-Object { $_.Path -eq $runtimeExe } |
Select-Object -First 1
if (-not $process) {
Fail "QSfera did not start from $runtimeExe"
}
Write-Output "OK: QSfera is running from $($process.Path) with PID $($process.Id)"
}
+201
View File
@@ -0,0 +1,201 @@
param(
[string]$ServerStatusUrl = "https://qsfera.kusoft.xyz/status.php",
[string]$ExpectedServerVersion = "7.0.0",
[string]$ExpectedServerEdition = "stable",
[string]$AndroidManifestUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable",
[string]$ExpectedAndroidSlug = "qsfera-mobile",
[string]$ExpectedAndroidPackageName = "eu.qsfera.android",
[string]$ExpectedAndroidVersion = "1.3.10",
[int]$ExpectedAndroidVersionCode = 38,
[string]$ExpectedAndroidChannel = "stable",
[string]$ExpectedAndroidPlatform = "android",
[string]$ExpectedAndroidPackageKind = "apk",
[string]$ExpectedAndroidSha256 = "ce33a73ce122de6c9c797e7cb552e5493b628c60ddc98fda6a9e230e3c1ed7fe",
[string]$AndroidDownloadUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable",
[switch]$VerifyAndroidDownload,
[string]$DesktopUpdateFeedUrl = "https://qsfera.kusoft.xyz/desktop/updates/stable.xml",
[switch]$RequireDesktopUpdate
)
$ErrorActionPreference = "Stop"
function Fail($Message) {
Write-Error $Message
exit 1
}
function Assert-AbsoluteHttpsUrl($Name, $Url) {
$parsedUrl = $null
if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$parsedUrl)) {
Fail "$Name must be an absolute URL: $Url"
}
if ($parsedUrl.Scheme -ne "https") {
Fail "$Name must use HTTPS: $Url"
}
}
function Assert-Equal($Name, $Actual, $Expected) {
if ([string]$Actual -ne [string]$Expected) {
Fail "$Name expected '$Expected' but got '$Actual'."
}
Write-Host "OK: $Name = $Actual"
}
function Assert-True($Name, [bool]$Value) {
if (-not $Value) {
Fail "$Name expected True but got False."
}
Write-Host "OK: $Name = True"
}
function Invoke-CheckedWebRequest($Name, $Url) {
Assert-AbsoluteHttpsUrl $Name $Url
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "$Name request failed for ${Url}: $($_.Exception.Message)"
}
if ($response.StatusCode -ne 200) {
Fail "$Name must return HTTP 200. Actual status: $($response.StatusCode)."
}
Write-Host "OK: $Name returned HTTP 200"
return $response
}
function Convert-JsonContent($Name, $Content) {
try {
return $Content | ConvertFrom-Json -ErrorAction Stop
} catch {
Fail "$Name returned invalid JSON: $($_.Exception.Message)"
}
}
function Get-RequiredProperty($Object, $Name, $Context) {
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property) {
Fail "$Context is missing '$Name'."
}
return $property.Value
}
function Test-Sha256String($Value) {
return [regex]::IsMatch($Value, "^[0-9a-f]{64}$")
}
Write-Host "Checking QSfera production server status..."
$serverResponse = Invoke-CheckedWebRequest "Server status endpoint" $ServerStatusUrl
$serverStatus = Convert-JsonContent "Server status endpoint" $serverResponse.Content
Assert-True "Server installed" ([bool](Get-RequiredProperty $serverStatus "installed" "Server status"))
Assert-Equal "Server maintenance" (Get-RequiredProperty $serverStatus "maintenance" "Server status") "False"
Assert-Equal "Server needsDbUpgrade" (Get-RequiredProperty $serverStatus "needsDbUpgrade" "Server status") "False"
Assert-Equal "Server edition" (Get-RequiredProperty $serverStatus "edition" "Server status") $ExpectedServerEdition
Assert-Equal "Server productversion" (Get-RequiredProperty $serverStatus "productversion" "Server status") $ExpectedServerVersion
Write-Host "Checking QSfera Android Argus manifest..."
$manifestResponse = Invoke-CheckedWebRequest "Android Argus manifest" $AndroidManifestUrl
$manifest = Convert-JsonContent "Android Argus manifest" $manifestResponse.Content
Assert-Equal "Android app slug" (Get-RequiredProperty $manifest "slug" "Android manifest") $ExpectedAndroidSlug
Assert-Equal "Android package name" (Get-RequiredProperty $manifest "androidPackageName" "Android manifest") $ExpectedAndroidPackageName
$release = Get-RequiredProperty $manifest "release" "Android manifest"
if ($null -eq $release) {
Fail "Android manifest release must not be null."
}
Assert-Equal "Android release version" (Get-RequiredProperty $release "version" "Android release") $ExpectedAndroidVersion
Assert-Equal "Android versionCode" (Get-RequiredProperty $release "androidVersionCode" "Android release") $ExpectedAndroidVersionCode
Assert-Equal "Android release channel" (Get-RequiredProperty $release "channel" "Android release") $ExpectedAndroidChannel
Assert-Equal "Android release platform" (Get-RequiredProperty $release "platform" "Android release") $ExpectedAndroidPlatform
Assert-Equal "Android package kind" (Get-RequiredProperty $release "packageKind" "Android release") $ExpectedAndroidPackageKind
$releaseSha256 = Get-RequiredProperty $release "sha256" "Android release"
Assert-True "Android SHA-256 shape" (Test-Sha256String $releaseSha256)
Assert-Equal "Android SHA-256" $releaseSha256 $ExpectedAndroidSha256
if ($VerifyAndroidDownload) {
Write-Host "Checking QSfera Android latest APK download..."
Assert-AbsoluteHttpsUrl "Android download URL" $AndroidDownloadUrl
$tempFile = [System.IO.Path]::GetTempFileName()
try {
try {
Invoke-WebRequest -Uri $AndroidDownloadUrl -UseBasicParsing -TimeoutSec 120 -OutFile $tempFile | Out-Null
} catch {
Fail "Android download request failed for ${AndroidDownloadUrl}: $($_.Exception.Message)"
}
$downloadSize = (Get-Item -LiteralPath $tempFile).Length
$expectedSize = Get-RequiredProperty $release "packageSizeBytes" "Android release"
Assert-Equal "Android APK size" $downloadSize $expectedSize
$downloadSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $tempFile).Hash.ToLowerInvariant()
Assert-Equal "Android APK download SHA-256" $downloadSha256 $ExpectedAndroidSha256
} finally {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Host "Checking QSfera Desktop update feed..."
$desktopResponse = Invoke-CheckedWebRequest "Desktop update feed" $DesktopUpdateFeedUrl
try {
[xml]$desktopFeed = $desktopResponse.Content
} catch {
Fail "Desktop update feed returned invalid XML: $($_.Exception.Message)"
}
if ($desktopFeed.DocumentElement.Name -ne "QSfera") {
Fail "Desktop update feed root element must be <QSfera>."
}
foreach ($elementName in @("version", "versionstring", "web", "downloadurl")) {
if ($null -eq $desktopFeed.QSfera.$elementName) {
Fail "Desktop update feed is missing <$elementName>."
}
}
$desktopWeb = [string]$desktopFeed.QSfera.web
if (-not [string]::IsNullOrWhiteSpace($desktopWeb)) {
Assert-AbsoluteHttpsUrl "Desktop update feed <web>" $desktopWeb
}
$desktopVersion = [string]$desktopFeed.QSfera.version
$desktopVersionString = [string]$desktopFeed.QSfera.versionstring
$desktopDownloadUrl = [string]$desktopFeed.QSfera.downloadurl
if ([string]::IsNullOrWhiteSpace($desktopVersion)) {
if ($RequireDesktopUpdate) {
Fail "Desktop update feed must advertise a concrete version when -RequireDesktopUpdate is set."
}
if (-not [string]::IsNullOrWhiteSpace($desktopDownloadUrl)) {
Fail "Desktop no-update feed must leave <downloadurl> empty when <version> is empty."
}
Write-Host "OK: Desktop update feed is a valid no-update feed"
} else {
$parsedVersion = $null
if (-not [System.Version]::TryParse($desktopVersion, [ref]$parsedVersion)) {
Fail "Desktop update feed <version> must be parseable as a version number."
}
if ([string]::IsNullOrWhiteSpace($desktopVersionString)) {
Fail "Desktop update feed must set <versionstring> when <version> is present."
}
Assert-AbsoluteHttpsUrl "Desktop update feed <downloadurl>" $desktopDownloadUrl
$downloadUri = [System.Uri]$desktopDownloadUrl
if (-not $downloadUri.AbsolutePath.EndsWith(".msi", [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "Desktop Windows updater expects an MSI download URL."
}
Write-Host "OK: Desktop update feed advertises version $desktopVersionString"
}
Write-Host "QSfera production endpoint verification passed."