Compare commits
16 Commits
main
..
8337bcb742
| Author | SHA1 | Date | |
|---|---|---|---|
| 8337bcb742 | |||
| c1c72900e5 | |||
| 89f3cd0934 | |||
| 4bf54f7fda | |||
| ccd20d76e7 | |||
| 9323f976d8 | |||
| 5e4dfc6642 | |||
| fa47f9b32e | |||
| ccc6bda044 | |||
| cf366b2699 | |||
| c9c4b1b17d | |||
| ac618d400b | |||
| 955be5134d | |||
| e9aa9f8315 | |||
| 458831c83c | |||
| 43caef7a6a |
@@ -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
|
||||
|
||||
|
||||
@@ -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 = 36
|
||||
versionName = "1.3.8"
|
||||
|
||||
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";
|
||||
|
||||
+51
-14
@@ -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"
|
||||
}
|
||||
|
||||
+34
-5
@@ -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
|
||||
|
||||
|
||||
+51
-14
@@ -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"
|
||||
}
|
||||
|
||||
+34
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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,6 +93,7 @@ class WorkManagerProvider(
|
||||
* concurrent scans or redundant enqueues on rapid foreground/background switches.
|
||||
*/
|
||||
fun enqueueImmediateAutomaticUploadsWorker() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
|
||||
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
|
||||
@@ -99,12 +103,12 @@ class WorkManagerProvider(
|
||||
|
||||
if (periodicRunning || immediateRunning) {
|
||||
Timber.d("Automatic uploads worker already running, skipping immediate run")
|
||||
return
|
||||
return@launch
|
||||
}
|
||||
|
||||
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
|
||||
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
|
||||
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
wm.enqueueUniqueWork(
|
||||
@@ -113,6 +117,7 @@ class WorkManagerProvider(
|
||||
immediateWorker
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueueOldLogsCollectorWorker() {
|
||||
val constraintsRequired = Constraints.Builder().setRequiredNetworkType(NetworkType.NOT_REQUIRED).build()
|
||||
|
||||
+11
-1
@@ -58,7 +58,7 @@ class UploadFileFromContentUriUseCase(
|
||||
.putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString())
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+115
-13
@@ -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>
|
||||
@@ -553,17 +553,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>
|
||||
@@ -591,17 +592,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"
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package eu.qsfera.android.usecases.transfers.uploads
|
||||
|
||||
import androidx.work.NetworkType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UploadFileFromContentUriUseCaseTest {
|
||||
|
||||
@Test
|
||||
fun `network type is unmetered when wifi only is enabled`() {
|
||||
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
|
||||
wifiOnly = true,
|
||||
allowRoaming = false
|
||||
)
|
||||
|
||||
assertEquals(NetworkType.UNMETERED, networkType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network type is connected when mobile data and roaming are allowed`() {
|
||||
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
|
||||
wifiOnly = false,
|
||||
allowRoaming = true
|
||||
)
|
||||
|
||||
assertEquals(NetworkType.CONNECTED, networkType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network type avoids roaming when mobile data is allowed without roaming`() {
|
||||
val networkType = UploadFileFromContentUriUseCase.getRequiredNetworkType(
|
||||
wifiOnly = false,
|
||||
allowRoaming = false
|
||||
)
|
||||
|
||||
assertEquals(NetworkType.NOT_ROAMING, networkType)
|
||||
}
|
||||
}
|
||||
+143
@@ -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
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+4
@@ -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'")
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
Vendored
+16
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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>
|
||||
```
|
||||
|
||||
Validate the live feed before a Windows production release:
|
||||
|
||||
```powershell
|
||||
./scripts/validate-desktop-update-feed.ps1 -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
|
||||
./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,85 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Url
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Fail($Message) {
|
||||
Write-Error $Message
|
||||
exit 1
|
||||
}
|
||||
|
||||
$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)"
|
||||
}
|
||||
|
||||
try {
|
||||
[xml]$xml = $response.Content
|
||||
} 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: $Url"
|
||||
@@ -0,0 +1,76 @@
|
||||
param(
|
||||
[switch]$RequireSigning,
|
||||
[switch]$VerifyUpdateFeed,
|
||||
[string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Fail($Message) {
|
||||
Write-Error $Message
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Read-Text($RelativePath) {
|
||||
Get-Content -LiteralPath (Join-Path $Root $RelativePath) -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
$branding = Read-Text "QSFERA.cmake"
|
||||
$cmake = Read-Text "CMakeLists.txt"
|
||||
$craft = Read-Text ".craft.ini"
|
||||
$expectedAppName = -join ([char[]](0x041a, 0x0443, 0x0421, 0x0444, 0x0435, 0x0440, 0x0430))
|
||||
|
||||
if (-not $branding.Contains("APPLICATION_NAME") -or -not $branding.Contains($expectedAppName)) {
|
||||
Fail "Desktop branding must use the production Cyrillic application name."
|
||||
}
|
||||
|
||||
if ($branding -notmatch 'APPLICATION_EXECUTABLE\s+"qsfera"') {
|
||||
Fail "Desktop executable must remain `"qsfera`" for the production profile."
|
||||
}
|
||||
|
||||
if ($branding -notmatch 'APPLICATION_REV_DOMAIN\s+"eu\.qsfera\.desktop"') {
|
||||
Fail "Desktop reverse-domain id must remain eu.qsfera.desktop for the production profile."
|
||||
}
|
||||
|
||||
if ($branding -notmatch 'APPLICATION_DEFAULT_SERVER_URL\s+"https://qsfera\.kusoft\.xyz"') {
|
||||
Fail "Desktop default server URL must be https://qsfera.kusoft.xyz."
|
||||
}
|
||||
|
||||
if ($branding -notmatch 'APPLICATION_UPDATE_URL\s+"https://qsfera\.kusoft\.xyz/desktop/updates/stable\.xml"') {
|
||||
Fail "Desktop update feed URL must be https://qsfera.kusoft.xyz/desktop/updates/stable.xml."
|
||||
}
|
||||
|
||||
if ($cmake -notmatch 'option\(WITH_UPDATE_NOTIFICATION\s+"Whether to check for new releases"\s+ON\)') {
|
||||
Fail "Desktop release builds must keep update notifications enabled by default."
|
||||
}
|
||||
|
||||
if ($cmake -notmatch 'VIRTUAL_FILE_SYSTEM_PLUGINS\s+off\s+cfapi\s+openvfs') {
|
||||
Fail "Desktop release builds must include the Windows CfAPI VFS plugin."
|
||||
}
|
||||
|
||||
if ($craft -notmatch 'CodeSigning/Enabled\s*=\s*\$\{Env:SIGN_PACKAGE\}') {
|
||||
Fail "Craft code-signing must be controlled by SIGN_PACKAGE."
|
||||
}
|
||||
|
||||
if ($RequireSigning) {
|
||||
if ($env:SIGN_PACKAGE -notin @("True", "true", "1", "yes", "YES")) {
|
||||
Fail "Production Windows tag releases require SIGN_PACKAGE=True."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:CRAFT_CODESIGN_CERTIFICATE)) {
|
||||
Fail "Production Windows tag releases require CRAFT_CODESIGN_CERTIFICATE."
|
||||
}
|
||||
|
||||
$VerifyUpdateFeed = $true
|
||||
}
|
||||
|
||||
if ($VerifyUpdateFeed) {
|
||||
$feedValidator = Join-Path $PSScriptRoot "validate-desktop-update-feed.ps1"
|
||||
if (-not (Test-Path -LiteralPath $feedValidator)) {
|
||||
Fail "Desktop update feed validator was not found: $feedValidator"
|
||||
}
|
||||
|
||||
& $feedValidator -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
|
||||
}
|
||||
|
||||
Write-Host "QSfera Desktop production release validation passed."
|
||||
@@ -194,13 +194,13 @@ void AccountSettings::slotRemoveCurrentFolder(Folder *folder)
|
||||
{
|
||||
// TODO: move to qml
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) QSfera
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
#include "common/version.h"
|
||||
#include "configfile.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
using namespace OCC;
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
class TestConfigFile : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void init()
|
||||
{
|
||||
QVERIFY(_configDir.isValid());
|
||||
QVERIFY(ConfigFile::setConfDir(_configDir.path()));
|
||||
QFile::remove(ConfigFile::configFile());
|
||||
}
|
||||
|
||||
void testDefaultUpdateChannelMatchesBuildChannel()
|
||||
{
|
||||
const auto expectedChannel = Version::isBeta() ? u"beta"_s : u"stable"_s;
|
||||
QCOMPARE(ConfigFile().updateChannel(), expectedChannel);
|
||||
}
|
||||
|
||||
void testStoredUpdateChannelOverridesBuildDefault()
|
||||
{
|
||||
ConfigFile().setUpdateChannel(u"beta"_s);
|
||||
QCOMPARE(ConfigFile().updateChannel(), u"beta"_s);
|
||||
|
||||
ConfigFile().setUpdateChannel(u"stable"_s);
|
||||
QCOMPARE(ConfigFile().updateChannel(), u"stable"_s);
|
||||
}
|
||||
|
||||
private:
|
||||
QTemporaryDir _configDir;
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestConfigFile)
|
||||
#include "testconfigfile.moc"
|
||||
@@ -230,12 +230,12 @@ File synchronization desktop utility.</source>
|
||||
<name>FolderDelegate</name>
|
||||
<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><p>Do you really want to stop syncing the Space »%1«?</p><p><b>Note:</b> This will <b>not</b> delete any files.</p></source>
|
||||
<source><p>Stop syncing <b>%1</b> on this computer?</p><p><b>Note:</b> This will not delete files in QSfera or on this device.</p></source>
|
||||
<translation type="unfinished"></translation>
|
||||
</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>
|
||||
|
||||
@@ -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><p>Do you really want to stop syncing the Space »%1«?</p><p><b>Note:</b> This will <b>not</b> delete any files.</p></source>
|
||||
<translation><p>Вы действительно хотите остановить синхронизацию Пространства »%1«? Примечание:</b> Это <b>не</b> приведет к удалению файлов.</p></translation>
|
||||
<source><p>Stop syncing <b>%1</b> on this computer?</p><p><b>Note:</b> This will not delete files in QSfera or on this device.</p></source>
|
||||
<translation><p>Остановить синхронизацию <b>%1</b> на этом компьютере?</p><p><b>Важно:</b> Файлы в QSfera и на этом устройстве не будут удалены.</p></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"/>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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.8` with `versionCode` `36`.
|
||||
- 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.8`/`36` was published to Argus as `qsfera-mobile` on 2026-06-09. Argus upload returned release id `404c0beb-d5ee-4f17-8f80-c59eb267117f`, package size `12225996`, and SHA-256 `872959380cfbfd858c11f388d430f8ce82ae8a9289a1e40bc8fa5b8d7c3897d8`.
|
||||
- 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`.
|
||||
- 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-09: server `edition=stable`/`productversion=7.0.0`, Android `1.3.8`/`36`, downloaded APK size `12225996`, APK SHA-256 `872959380cfbfd858c11f388d430f8ce82ae8a9289a1e40bc8fa5b8d7c3897d8`, 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 `404c0beb-d5ee-4f17-8f80-c59eb267117f`, version `1.3.8`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `36`, package size `12225996`, and SHA-256 `872959380cfbfd858c11f388d430f8ce82ae8a9289a1e40bc8fa5b8d7c3897d8`.
|
||||
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12225996` byte APK with the same SHA-256 `872959380cfbfd858c11f388d430f8ce82ae8a9289a1e40bc8fa5b8d7c3897d8`.
|
||||
- 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.8`/`36` 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.8`.
|
||||
|
||||
## 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.8` cannot be signed with the same certificate.
|
||||
- Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
|
||||
- Decide the rollout policy for any devices that installed the debug-signed Argus `1.3.5` APK. The production-signed `1.3.8` APK is the correct current release, but installed debug-signed clients will not satisfy the app's own signer-digest check for an in-place update.
|
||||
- Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values.
|
||||
- Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified.
|
||||
- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference.
|
||||
- Publish the first signed Windows MSI and update `https://qsfera.kusoft.xyz/desktop/updates/stable.xml` from the current no-update XML to a concrete `<version>`, `<versionstring>`, and HTTPS `.msi` `<downloadurl>`.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
QSFERA_RPI_IMAGE=qsfera-cloud-server:7.0.0-rpi5-stable
|
||||
QSFERA_VERSION=7.0.0
|
||||
QSFERA_EDITION=stable
|
||||
QSFERA_BUILD_DATE=20260608
|
||||
QSFERA_RPI_HOST_PORT=9200
|
||||
QSFERA_RPI_DEBUG_HOST_PORT=9205
|
||||
QSFERA_RPI_CONFIG_PATH=/mnt/data/qsfera/cloud-server/config
|
||||
QSFERA_RPI_DATA_PATH=/mnt/data/qsfera/cloud-server/data
|
||||
|
||||
OC_URL=https://qsfera.example.com
|
||||
OC_LOG_LEVEL=info
|
||||
OC_INSECURE=false
|
||||
PROXY_TLS=true
|
||||
PROXY_DEBUG_ADDR=0.0.0.0:9205
|
||||
PROXY_DEBUG_TOKEN=
|
||||
FRONTEND_CHECK_FOR_UPDATES=false
|
||||
@@ -61,6 +61,7 @@ protogen/buf.sha1.lock
|
||||
go.work
|
||||
go.work.sum
|
||||
.env
|
||||
.env.server
|
||||
.envrc
|
||||
.DS_Store
|
||||
|
||||
|
||||
+11
-2
@@ -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)."
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
qsfera-cloud-server:
|
||||
container_name: qsfera-cloud-server
|
||||
image: ${QSFERA_RPI_IMAGE:-qsfera-cloud-server:7.0.0-rpi5-stable}
|
||||
platform: linux/arm64
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
TARGETARCH: arm64
|
||||
QSFERA_VERSION: ${QSFERA_VERSION:-7.0.0}
|
||||
QSFERA_EDITION: ${QSFERA_EDITION:-stable}
|
||||
QSFERA_BUILD_DATE: ${QSFERA_BUILD_DATE:-20260608}
|
||||
restart: unless-stopped
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- qsfera init || true; exec qsfera server
|
||||
environment:
|
||||
OC_LOG_LEVEL: ${OC_LOG_LEVEL:-info}
|
||||
FRONTEND_CHECK_FOR_UPDATES: ${FRONTEND_CHECK_FOR_UPDATES:-false}
|
||||
OC_CONFIG_DIR: /etc/qsfera
|
||||
OC_BASE_DATA_PATH: /var/lib/qsfera
|
||||
OC_URL: ${OC_URL:?OC_URL must be set in .env}
|
||||
OC_INSECURE: ${OC_INSECURE:-false}
|
||||
PROXY_TLS: ${PROXY_TLS:-true}
|
||||
PROXY_DEBUG_ADDR: ${PROXY_DEBUG_ADDR:-0.0.0.0:9205}
|
||||
PROXY_DEBUG_TOKEN: ${PROXY_DEBUG_TOKEN:-}
|
||||
ports:
|
||||
- "127.0.0.1:${QSFERA_RPI_HOST_PORT:-9200}:9200"
|
||||
- "127.0.0.1:${QSFERA_RPI_DEBUG_HOST_PORT:-9205}:9205"
|
||||
volumes:
|
||||
- ${QSFERA_RPI_CONFIG_PATH:?QSFERA_RPI_CONFIG_PATH must be set in .env}:/etc/qsfera
|
||||
- ${QSFERA_RPI_DATA_PATH:?QSFERA_RPI_DATA_PATH must be set in .env}:/var/lib/qsfera
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -kfsS https://127.0.0.1:9200/status.php >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
@@ -0,0 +1,292 @@
|
||||
# Raspberry Pi 5 Docker Run
|
||||
|
||||
This server repository contains a Dockerized QSfera path for Raspberry Pi 5 and other
|
||||
64-bit ARM Linux hosts.
|
||||
|
||||
Files used for this deployment:
|
||||
|
||||
- `Dockerfile`
|
||||
- `docker-compose.rpi5.yml`
|
||||
- `.env.example`
|
||||
|
||||
## What This Runs
|
||||
|
||||
- QSfera server from this `Server` source tree.
|
||||
- A pinned production image tag by default: `qsfera-cloud-server:7.0.0-rpi5-stable`.
|
||||
- Build metadata injected into the binary through Docker build args:
|
||||
`QSFERA_VERSION`, `QSFERA_EDITION`, and `QSFERA_BUILD_DATE`.
|
||||
- QSfera config persisted under `/etc/qsfera` inside the container.
|
||||
- QSfera data persisted under `/var/lib/qsfera` inside the container.
|
||||
- Host-side config and data paths controlled by `QSFERA_RPI_CONFIG_PATH` and
|
||||
`QSFERA_RPI_DATA_PATH`.
|
||||
- Host-side backend port bound only to `127.0.0.1:9200`; external access should go
|
||||
through the reverse proxy and HTTPS domain.
|
||||
- Host-side proxy debug/metrics port bound only to `127.0.0.1:9205`.
|
||||
|
||||
## Prerequisites On Raspberry Pi 5
|
||||
|
||||
1. Install Docker Engine using the official Docker docs: `https://docs.docker.com/engine/install/debian/`.
|
||||
2. Install the Docker Compose plugin using the official Docker docs: `https://docs.docker.com/compose/install/`.
|
||||
3. Use a 64-bit OS. The compose file sets `platform: linux/arm64`, and the Dockerfile builds with `TARGETARCH=arm64`.
|
||||
4. Put `QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` on durable storage, not on a nearly full SD-card root filesystem.
|
||||
5. Set `OC_URL` to the public HTTPS URL used by clients and the reverse proxy.
|
||||
6. Keep `QSFERA_EDITION=stable` and a concrete `QSFERA_VERSION` for production
|
||||
rollouts. Leaving those unset falls back to the defaults in the compose file.
|
||||
7. Keep Docker image and builder retention under control. The current Raspberry Pi
|
||||
deployment stores QSfera data on `/mnt/data`; Docker's own `data-root` must
|
||||
either be moved there during a maintenance window or pruned on a schedule.
|
||||
|
||||
## Start
|
||||
|
||||
Run from the `Server` directory:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.rpi5.yml up -d --build
|
||||
```
|
||||
|
||||
Verify the image metadata before rollout:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.rpi5.yml build
|
||||
docker run --rm --entrypoint /usr/bin/qsfera qsfera-cloud-server:7.0.0-rpi5-stable version --skip-services
|
||||
```
|
||||
|
||||
Run the production checker from the `Server` directory after every rollout:
|
||||
|
||||
```bash
|
||||
PUBLIC_STATUS_URL=https://qsfera.example.com/status.php \
|
||||
./scripts/verify-rpi-production.sh
|
||||
```
|
||||
|
||||
## Stop
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.rpi5.yml down
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.rpi5.yml logs -f qsfera-cloud-server
|
||||
```
|
||||
|
||||
## Update After Pulling New Code
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.rpi5.yml up -d --build
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
Local backend status endpoint:
|
||||
|
||||
```bash
|
||||
curl -k https://127.0.0.1:9200/status.php
|
||||
```
|
||||
|
||||
External reverse-proxied status endpoint:
|
||||
|
||||
```bash
|
||||
curl https://qsfera.example.com/status.php
|
||||
```
|
||||
|
||||
Local Prometheus metrics endpoint:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9205/metrics
|
||||
```
|
||||
|
||||
Repeatable production gate from the `Server` directory:
|
||||
|
||||
```bash
|
||||
PUBLIC_STATUS_URL=https://qsfera.example.com/status.php \
|
||||
REQUIRE_METRICS=true \
|
||||
./scripts/verify-rpi-production.sh
|
||||
```
|
||||
|
||||
The checker validates:
|
||||
|
||||
- Docker Compose config rendering.
|
||||
- Local presence of the pinned image.
|
||||
- Image metadata from `qsfera version --skip-services`.
|
||||
- Container running state, Docker health status, and container image tag.
|
||||
- Docker root location, root filesystem free space, and data filesystem free space.
|
||||
- Local and optional public `/status.php` JSON invariants:
|
||||
`installed=true`, `maintenance=false`, `needsDbUpgrade=false`,
|
||||
`edition=stable`, and `productversion=7.0.0`.
|
||||
- Optional Prometheus metrics reachability.
|
||||
|
||||
The default free-space thresholds are 5 GiB for `/` and 20 GiB for `/mnt/data`.
|
||||
Override them with `MIN_ROOT_AVAILABLE_KB` and `MIN_DATA_AVAILABLE_KB` when the
|
||||
host has a documented capacity plan.
|
||||
|
||||
## Data Location
|
||||
|
||||
Docker bind mounts:
|
||||
|
||||
- host config: `${QSFERA_RPI_CONFIG_PATH}`
|
||||
- container config: `/etc/qsfera`
|
||||
- host data: `${QSFERA_RPI_DATA_PATH}`
|
||||
- container data: `/var/lib/qsfera`
|
||||
|
||||
For the current Raspberry Pi deployment checked on 2026-06-09 after the metrics
|
||||
rollout, the existing container uses:
|
||||
|
||||
- host config: `/mnt/data/qsfera/cloud-server/config`
|
||||
- host data: `/mnt/data/qsfera/cloud-server/data`
|
||||
- image: `qsfera-cloud-server:7.0.0-rpi5-stable`
|
||||
- public URL: `https://qsfera.kusoft.xyz`
|
||||
- loopback port: `127.0.0.1:9200`
|
||||
- loopback metrics port: `127.0.0.1:9205`
|
||||
- Docker data-root: `/mnt/data/docker`
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
The server is intentionally bound to loopback. Put Caddy, Nginx, Traefik, or another
|
||||
reverse proxy in front of it for external HTTPS access.
|
||||
|
||||
The inspected Raspberry Pi uses Caddy with this QSfera route:
|
||||
|
||||
```caddyfile
|
||||
qsfera.kusoft.xyz {
|
||||
encode zstd gzip
|
||||
handle {
|
||||
reverse_proxy https://127.0.0.1:9200 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not proxy `127.0.0.1:9205` to the public Internet. If a remote Prometheus
|
||||
server needs to scrape it, connect through a private tunnel or bind it to an
|
||||
internal-only interface and set `PROXY_DEBUG_TOKEN`.
|
||||
|
||||
## Monitoring And Alerts
|
||||
|
||||
Install the production checker timer from the `Server` directory:
|
||||
|
||||
```bash
|
||||
sudo ./scripts/install-rpi-production-monitoring.sh
|
||||
```
|
||||
|
||||
The installer writes:
|
||||
|
||||
- `/etc/systemd/system/qsfera-rpi-production-check.service`
|
||||
- `/etc/systemd/system/qsfera-rpi-production-check.timer`
|
||||
- `/etc/systemd/system/qsfera-rpi-production-check-alert@.service`
|
||||
- `/etc/qsfera/rpi-production-check.env`
|
||||
|
||||
The timer runs `./scripts/verify-rpi-production.sh` every 5 minutes. The service
|
||||
uses `OnFailure=qsfera-rpi-production-check-alert@%n.service`; the alert service
|
||||
logs the failed unit and recent journal output through `systemd-cat`. To forward
|
||||
alerts off-host, set `QSFERA_ALERT_WEBHOOK_URL` in
|
||||
`/etc/qsfera/rpi-production-check.env`; `alert-rpi-production-check-failure.sh`
|
||||
sends a JSON `POST` when that variable is set.
|
||||
|
||||
For the current Raspberry Pi deployment checked on 2026-06-09, the timer was
|
||||
installed under `/etc/systemd/system`, `systemctl is-enabled` returned
|
||||
`enabled`, `systemctl is-active` returned `active`, and the checker service
|
||||
reported `Result=success`, `ExecMainStatus=0`, and `ActiveState=inactive` after
|
||||
the initial run. `systemctl list-timers qsfera-rpi-production-check.timer`
|
||||
reported the next run 5 minutes after the previous run.
|
||||
|
||||
Verify the timer:
|
||||
|
||||
```bash
|
||||
systemctl list-timers qsfera-rpi-production-check.timer --no-pager
|
||||
systemctl status qsfera-rpi-production-check.timer --no-pager
|
||||
systemctl show qsfera-rpi-production-check.service -p Result -p ExecMainStatus -p ActiveState
|
||||
journalctl -u qsfera-rpi-production-check.service -n 80 --no-pager
|
||||
```
|
||||
|
||||
Minimum monitoring expectations:
|
||||
|
||||
- Keep `qsfera-rpi-production-check.timer` enabled and active.
|
||||
- Investigate any non-zero `ExecMainStatus` from
|
||||
`qsfera-rpi-production-check.service`.
|
||||
- Investigate any `WARN:` line that persists for more than one day, especially
|
||||
Docker root outside `/mnt/data` or unavailable metrics.
|
||||
- Configure `QSFERA_ALERT_WEBHOOK_URL` when off-host alert delivery is required;
|
||||
without it, failures are still routed to the systemd failure service and
|
||||
journal, but not to an external notification endpoint.
|
||||
- Scrape `http://127.0.0.1:9205/metrics` locally or through a private tunnel.
|
||||
- Keep public HTTPS probing separate from local loopback probing so reverse-proxy
|
||||
and backend failures are distinguishable.
|
||||
- Keep `OC_LOG_LEVEL=info` for production; raise to `debug` only for a bounded
|
||||
incident window.
|
||||
|
||||
For larger deployments, the full compose example already contains tracing and
|
||||
metrics wiring in `devtools/deployments/qsfera_full/monitoring_tracing/`.
|
||||
|
||||
## Backups And Restore Drill
|
||||
|
||||
Back up the two bind-mounted paths from `.env`:
|
||||
|
||||
- `QSFERA_RPI_CONFIG_PATH`
|
||||
- `QSFERA_RPI_DATA_PATH`
|
||||
|
||||
Create an archive before every server image rollout:
|
||||
|
||||
```bash
|
||||
stamp="$(date -u +%Y%m%d-%H%M%S)"
|
||||
backup="/mnt/data/qsfera/backups/cloud-server-${stamp}.tar.gz"
|
||||
sudo tar -C /mnt/data/qsfera/cloud-server -czf "$backup" config data
|
||||
sudo tar -tzf "$backup" >/dev/null
|
||||
```
|
||||
|
||||
Run a restore drill after backup automation changes and at least monthly:
|
||||
|
||||
```bash
|
||||
stamp="$(date -u +%Y%m%d-%H%M%S)"
|
||||
restore_root="/mnt/data/qsfera/restore-drills/${stamp}"
|
||||
sudo mkdir -p "$restore_root"
|
||||
sudo tar -C "$restore_root" -xzf "$backup"
|
||||
sudo test -d "$restore_root/config"
|
||||
sudo test -d "$restore_root/data"
|
||||
```
|
||||
|
||||
For a full service restore test, copy `.env` to a drill-only file, point
|
||||
`QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` at the extracted directories,
|
||||
bind `QSFERA_RPI_HOST_PORT` to an unused loopback port, run
|
||||
`docker compose -f docker-compose.rpi5.yml --env-file .env.restore config`, and
|
||||
only then start the drill container. Do not run a restore drill against the live
|
||||
config/data paths.
|
||||
|
||||
## Docker Data-Root And Retention
|
||||
|
||||
Check where Docker stores images, layers, and build cache:
|
||||
|
||||
```bash
|
||||
docker info --format '{{.DockerRootDir}}'
|
||||
docker system df
|
||||
```
|
||||
|
||||
Preferred production state on this Pi is Docker data-root under `/mnt/data`,
|
||||
because the QSfera bind-mounted data is already stored there. Move it only during
|
||||
a maintenance window:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop docker
|
||||
sudo mkdir -p /mnt/data/docker
|
||||
sudo rsync -aHAX --numeric-ids /var/lib/docker/ /mnt/data/docker/
|
||||
printf '{\n "data-root": "/mnt/data/docker"\n}\n' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl start docker
|
||||
docker info --format '{{.DockerRootDir}}'
|
||||
docker compose -f docker-compose.rpi5.yml ps
|
||||
```
|
||||
|
||||
If moving Docker data-root is postponed, keep conservative retention active:
|
||||
|
||||
```bash
|
||||
docker builder prune --filter 'until=168h'
|
||||
docker image prune --filter 'until=168h'
|
||||
docker system df
|
||||
```
|
||||
|
||||
Do not run `docker system prune --volumes` on this host unless a current backup
|
||||
has been verified and the target volumes have been explicitly reviewed.
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
FAILED_UNIT="${1:-qsfera-rpi-production-check.service}"
|
||||
JOURNAL_LINES="${QSFERA_ALERT_JOURNAL_LINES:-80}"
|
||||
HOSTNAME_VALUE="$(hostname -f 2>/dev/null || hostname)"
|
||||
TIMESTAMP_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
SUMMARY="QSfera Raspberry Pi production check failed on ${HOSTNAME_VALUE}: ${FAILED_UNIT} at ${TIMESTAMP_UTC}"
|
||||
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
JOURNAL_OUTPUT="$(journalctl -u "$FAILED_UNIT" -n "$JOURNAL_LINES" --no-pager 2>/dev/null || true)"
|
||||
else
|
||||
JOURNAL_OUTPUT=""
|
||||
fi
|
||||
|
||||
{
|
||||
echo "$SUMMARY"
|
||||
if [ -n "$JOURNAL_OUTPUT" ]; then
|
||||
echo
|
||||
echo "$JOURNAL_OUTPUT"
|
||||
fi
|
||||
} | if command -v systemd-cat >/dev/null 2>&1; then
|
||||
systemd-cat -t qsfera-rpi-production-alert -p err
|
||||
else
|
||||
cat >&2
|
||||
fi
|
||||
|
||||
if [ -n "${QSFERA_ALERT_WEBHOOK_URL:-}" ]; then
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "ERROR: python3 is required to format the alert webhook payload." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "ERROR: curl is required to send the alert webhook." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$SUMMARY" "$FAILED_UNIT" "$HOSTNAME_VALUE" "$TIMESTAMP_UTC" "$JOURNAL_OUTPUT" <<'PY' |
|
||||
import json
|
||||
import sys
|
||||
|
||||
summary, failed_unit, hostname, timestamp_utc, journal = sys.argv[1:]
|
||||
print(json.dumps({
|
||||
"summary": summary,
|
||||
"failedUnit": failed_unit,
|
||||
"host": hostname,
|
||||
"timestampUtc": timestamp_utc,
|
||||
"journal": journal,
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
curl -fsS \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- \
|
||||
"$QSFERA_ALERT_WEBHOOK_URL" >/dev/null
|
||||
fi
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
SERVER_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
|
||||
ENV_DIR="${ENV_DIR:-/etc/qsfera}"
|
||||
ENV_FILE="${ENV_FILE:-$ENV_DIR/rpi-production-check.env}"
|
||||
RUN_NOW="${RUN_NOW:-true}"
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="${SUDO:-sudo}"
|
||||
fi
|
||||
|
||||
run_privileged() {
|
||||
if [ -n "$SUDO" ]; then
|
||||
"$SUDO" "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
install_unit() {
|
||||
source_path="$1"
|
||||
target_path="$2"
|
||||
run_privileged install -m 0644 "$source_path" "$target_path"
|
||||
}
|
||||
|
||||
tmp_env="$(mktemp)"
|
||||
cleanup() {
|
||||
rm -f "$tmp_env"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
run_privileged install -d -m 0755 "$SYSTEMD_DIR" "$ENV_DIR"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
{
|
||||
echo "QSFERA_SERVER_DIR=$SERVER_DIR"
|
||||
grep -v '^QSFERA_SERVER_DIR=' "$SERVER_DIR/systemd/rpi-production-check.env.example"
|
||||
} >"$tmp_env"
|
||||
run_privileged install -m 0640 "$tmp_env" "$ENV_FILE"
|
||||
echo "Created $ENV_FILE"
|
||||
else
|
||||
echo "Keeping existing $ENV_FILE"
|
||||
fi
|
||||
|
||||
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check.service" "$SYSTEMD_DIR/qsfera-rpi-production-check.service"
|
||||
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check.timer" "$SYSTEMD_DIR/qsfera-rpi-production-check.timer"
|
||||
install_unit "$SERVER_DIR/systemd/qsfera-rpi-production-check-alert@.service" "$SYSTEMD_DIR/qsfera-rpi-production-check-alert@.service"
|
||||
|
||||
run_privileged systemctl daemon-reload
|
||||
run_privileged systemctl enable --now qsfera-rpi-production-check.timer
|
||||
|
||||
if [ "$RUN_NOW" = "true" ]; then
|
||||
run_privileged systemctl start qsfera-rpi-production-check.service
|
||||
fi
|
||||
|
||||
run_privileged systemctl --no-pager status qsfera-rpi-production-check.timer
|
||||
run_privileged systemctl --no-pager show qsfera-rpi-production-check.service -p Result -p ExecMainStatus -p ActiveState
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.rpi5.yml}"
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
SERVICE="${SERVICE:-qsfera-cloud-server}"
|
||||
CONTAINER="${CONTAINER:-qsfera-cloud-server}"
|
||||
EXPECTED_IMAGE="${EXPECTED_IMAGE:-qsfera-cloud-server:7.0.0-rpi5-stable}"
|
||||
EXPECTED_VERSION="${EXPECTED_VERSION:-7.0.0}"
|
||||
EXPECTED_EDITION="${EXPECTED_EDITION:-stable}"
|
||||
LOCAL_STATUS_URL="${LOCAL_STATUS_URL:-https://127.0.0.1:9200/status.php}"
|
||||
PUBLIC_STATUS_URL="${PUBLIC_STATUS_URL:-}"
|
||||
METRICS_URL="${METRICS_URL:-http://127.0.0.1:9205/metrics}"
|
||||
REQUIRE_METRICS="${REQUIRE_METRICS:-false}"
|
||||
VERIFY_IMAGE_VERSION="${VERIFY_IMAGE_VERSION:-true}"
|
||||
ROOT_PATH="${ROOT_PATH:-/}"
|
||||
DATA_PATH="${DATA_PATH:-/mnt/data}"
|
||||
MIN_ROOT_AVAILABLE_KB="${MIN_ROOT_AVAILABLE_KB:-5242880}"
|
||||
MIN_DATA_AVAILABLE_KB="${MIN_DATA_AVAILABLE_KB:-20971520}"
|
||||
EXPECTED_DOCKER_ROOT_PREFIX="${EXPECTED_DOCKER_ROOT_PREFIX:-/mnt/data}"
|
||||
STRICT_DOCKER_ROOT="${STRICT_DOCKER_ROOT:-false}"
|
||||
|
||||
errors=0
|
||||
warnings=0
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
ok() {
|
||||
echo "OK: $*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
warnings=$((warnings + 1))
|
||||
echo "WARN: $*" >&2
|
||||
}
|
||||
|
||||
error() {
|
||||
errors=$((errors + 1))
|
||||
echo "ERROR: $*" >&2
|
||||
}
|
||||
|
||||
is_true() {
|
||||
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
||||
true|1|yes|on)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if command -v "$1" >/dev/null 2>&1; then
|
||||
ok "Command available: $1"
|
||||
else
|
||||
error "Required command is missing: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||
else
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
available_kb() {
|
||||
df -Pk "$1" 2>/dev/null | awk 'NR == 2 { print $4 }'
|
||||
}
|
||||
|
||||
format_gib() {
|
||||
awk -v kb="$1" 'BEGIN { printf "%.1f", kb / 1048576 }'
|
||||
}
|
||||
|
||||
check_disk() {
|
||||
path="$1"
|
||||
minimum_kb="$2"
|
||||
label="$3"
|
||||
current_kb="$(available_kb "$path")"
|
||||
|
||||
if [ -z "$current_kb" ]; then
|
||||
error "Cannot read free disk space for $label at $path."
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$current_kb" -ge "$minimum_kb" ]; then
|
||||
ok "$label free space $(format_gib "$current_kb") GiB >= $(format_gib "$minimum_kb") GiB"
|
||||
else
|
||||
error "$label free space $(format_gib "$current_kb") GiB < $(format_gib "$minimum_kb") GiB"
|
||||
fi
|
||||
}
|
||||
|
||||
check_status_url() {
|
||||
label="$1"
|
||||
url="$2"
|
||||
curl_opts="$3"
|
||||
output="$tmp_dir/status.json"
|
||||
|
||||
if curl $curl_opts "$url" -o "$output"; then
|
||||
ok "$label returned a successful HTTP response"
|
||||
else
|
||||
error "$label request failed: $url"
|
||||
return
|
||||
fi
|
||||
|
||||
if python3 - "$output" "$EXPECTED_EDITION" "$EXPECTED_VERSION" "$label" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path, expected_edition, expected_version, label = sys.argv[1:]
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
checks = [
|
||||
("installed", payload.get("installed") is True, True, payload.get("installed")),
|
||||
("maintenance", payload.get("maintenance") is False, False, payload.get("maintenance")),
|
||||
("needsDbUpgrade", payload.get("needsDbUpgrade") is False, False, payload.get("needsDbUpgrade")),
|
||||
("edition", payload.get("edition") == expected_edition, expected_edition, payload.get("edition")),
|
||||
("productversion", payload.get("productversion") == expected_version, expected_version, payload.get("productversion")),
|
||||
]
|
||||
|
||||
failed = False
|
||||
for name, passed, expected, actual in checks:
|
||||
if passed:
|
||||
print(f"OK: {label} {name} = {actual}")
|
||||
else:
|
||||
print(f"ERROR: {label} {name}: expected {expected!r}, got {actual!r}", file=sys.stderr)
|
||||
failed = True
|
||||
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
PY
|
||||
then
|
||||
ok "$label JSON invariants match expected production state"
|
||||
else
|
||||
error "$label JSON invariants failed"
|
||||
fi
|
||||
}
|
||||
|
||||
check_metrics() {
|
||||
output="$tmp_dir/metrics.txt"
|
||||
|
||||
if [ -n "${METRICS_BEARER_TOKEN:-}" ]; then
|
||||
metrics_result=0
|
||||
curl -fsS -H "Authorization: Bearer ${METRICS_BEARER_TOKEN}" "$METRICS_URL" -o "$output" || metrics_result=$?
|
||||
else
|
||||
metrics_result=0
|
||||
curl -fsS "$METRICS_URL" -o "$output" || metrics_result=$?
|
||||
fi
|
||||
|
||||
if [ "$metrics_result" -eq 0 ]; then
|
||||
if grep -Eq '^(# HELP|# TYPE|go_|process_)' "$output"; then
|
||||
ok "Metrics endpoint returned Prometheus-formatted content"
|
||||
else
|
||||
error "Metrics endpoint responded, but content does not look like Prometheus metrics."
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if is_true "$REQUIRE_METRICS"; then
|
||||
error "Metrics endpoint is required but unavailable: $METRICS_URL"
|
||||
else
|
||||
warn "Metrics endpoint unavailable: $METRICS_URL"
|
||||
fi
|
||||
}
|
||||
|
||||
for command_name in docker curl python3 awk df; do
|
||||
require_command "$command_name"
|
||||
done
|
||||
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
error "Compose file not found: $COMPOSE_FILE"
|
||||
fi
|
||||
|
||||
if [ -n "$ENV_FILE" ] && [ ! -f "$ENV_FILE" ]; then
|
||||
error "Environment file not found: $ENV_FILE"
|
||||
fi
|
||||
|
||||
if [ "$errors" -eq 0 ]; then
|
||||
if compose config >/dev/null; then
|
||||
ok "Docker Compose config renders successfully for $COMPOSE_FILE"
|
||||
else
|
||||
error "Docker Compose config failed for $COMPOSE_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if docker image inspect "$EXPECTED_IMAGE" >/dev/null 2>&1; then
|
||||
ok "Expected image exists locally: $EXPECTED_IMAGE"
|
||||
else
|
||||
error "Expected image is missing locally: $EXPECTED_IMAGE"
|
||||
fi
|
||||
|
||||
if is_true "$VERIFY_IMAGE_VERSION"; then
|
||||
version_output="$tmp_dir/image-version.txt"
|
||||
if docker run --rm --entrypoint /usr/bin/qsfera "$EXPECTED_IMAGE" version --skip-services >"$version_output" 2>&1; then
|
||||
if grep -q "Version: $EXPECTED_VERSION" "$version_output" && grep -q "Edition: $EXPECTED_EDITION" "$version_output"; then
|
||||
ok "Image version metadata matches $EXPECTED_EDITION $EXPECTED_VERSION"
|
||||
else
|
||||
error "Image version metadata does not match expected $EXPECTED_EDITION $EXPECTED_VERSION."
|
||||
sed 's/^/ /' "$version_output" >&2
|
||||
fi
|
||||
else
|
||||
error "Cannot read image version metadata from $EXPECTED_IMAGE."
|
||||
sed 's/^/ /' "$version_output" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
container_status="$(docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>/dev/null || true)"
|
||||
if [ "$container_status" = "running" ]; then
|
||||
ok "Container $CONTAINER is running"
|
||||
else
|
||||
error "Container $CONTAINER status is '${container_status:-missing}', expected running."
|
||||
fi
|
||||
|
||||
container_health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CONTAINER" 2>/dev/null || true)"
|
||||
case "$container_health" in
|
||||
healthy)
|
||||
ok "Container $CONTAINER health is healthy"
|
||||
;;
|
||||
none)
|
||||
warn "Container $CONTAINER has no Docker healthcheck result."
|
||||
;;
|
||||
*)
|
||||
error "Container $CONTAINER health is '${container_health:-missing}', expected healthy."
|
||||
;;
|
||||
esac
|
||||
|
||||
container_image="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER" 2>/dev/null || true)"
|
||||
if [ "$container_image" = "$EXPECTED_IMAGE" ]; then
|
||||
ok "Container image is $EXPECTED_IMAGE"
|
||||
else
|
||||
error "Container image is '${container_image:-missing}', expected $EXPECTED_IMAGE."
|
||||
fi
|
||||
|
||||
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
|
||||
case "$docker_root" in
|
||||
"$EXPECTED_DOCKER_ROOT_PREFIX"|"$EXPECTED_DOCKER_ROOT_PREFIX"/*)
|
||||
ok "Docker root is under $EXPECTED_DOCKER_ROOT_PREFIX: $docker_root"
|
||||
;;
|
||||
"")
|
||||
error "Cannot read Docker root directory."
|
||||
;;
|
||||
*)
|
||||
if is_true "$STRICT_DOCKER_ROOT"; then
|
||||
error "Docker root is $docker_root, expected path under $EXPECTED_DOCKER_ROOT_PREFIX."
|
||||
else
|
||||
warn "Docker root is $docker_root, not under $EXPECTED_DOCKER_ROOT_PREFIX; keep image/build retention active or move data-root during maintenance."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
check_disk "$ROOT_PATH" "$MIN_ROOT_AVAILABLE_KB" "Root filesystem"
|
||||
check_disk "$DATA_PATH" "$MIN_DATA_AVAILABLE_KB" "Data filesystem"
|
||||
check_status_url "Local server status" "$LOCAL_STATUS_URL" "-kfsS"
|
||||
|
||||
if [ -n "$PUBLIC_STATUS_URL" ]; then
|
||||
check_status_url "Public server status" "$PUBLIC_STATUS_URL" "-fsS"
|
||||
fi
|
||||
|
||||
check_metrics
|
||||
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
echo "QSfera Raspberry Pi production verification failed: $errors error(s), $warnings warning(s)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "QSfera Raspberry Pi production verification passed: $warnings warning(s)."
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Report QSfera Raspberry Pi production check failure for %I
|
||||
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=-/etc/qsfera/rpi-production-check.env
|
||||
ExecStart=/bin/sh -c 'cd "${QSFERA_SERVER_DIR:-/mnt/data/qsfera/cloud-server/src}" && exec ./scripts/alert-rpi-production-check-failure.sh "$1"' -- "%I"
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=QSfera Raspberry Pi production verification
|
||||
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target docker.service
|
||||
Requires=docker.service
|
||||
OnFailure=qsfera-rpi-production-check-alert@%n.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=-/etc/qsfera/rpi-production-check.env
|
||||
ExecStart=/bin/sh -c 'cd "${QSFERA_SERVER_DIR:-/mnt/data/qsfera/cloud-server/src}" && exec ./scripts/verify-rpi-production.sh'
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Run QSfera Raspberry Pi production verification every five minutes
|
||||
Documentation=file:/mnt/data/qsfera/cloud-server/src/docs/raspberry-pi-5-docker.md
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
AccuracySec=30s
|
||||
Persistent=true
|
||||
Unit=qsfera-rpi-production-check.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,15 @@
|
||||
QSFERA_SERVER_DIR=/mnt/data/qsfera/cloud-server/src
|
||||
PUBLIC_STATUS_URL=https://qsfera.kusoft.xyz/status.php
|
||||
REQUIRE_METRICS=true
|
||||
METRICS_URL=http://127.0.0.1:9205/metrics
|
||||
EXPECTED_IMAGE=qsfera-cloud-server:7.0.0-rpi5-stable
|
||||
EXPECTED_VERSION=7.0.0
|
||||
EXPECTED_EDITION=stable
|
||||
EXPECTED_DOCKER_ROOT_PREFIX=/mnt/data
|
||||
STRICT_DOCKER_ROOT=true
|
||||
MIN_ROOT_AVAILABLE_KB=5242880
|
||||
MIN_DATA_AVAILABLE_KB=20971520
|
||||
QSFERA_ALERT_JOURNAL_LINES=80
|
||||
|
||||
# Optional off-host alert endpoint. The alert script sends a JSON POST when set.
|
||||
#QSFERA_ALERT_WEBHOOK_URL=https://alerts.example.com/qsfera-rpi
|
||||
@@ -0,0 +1,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.8",
|
||||
[int]$ExpectedAndroidVersionCode = 36,
|
||||
[string]$ExpectedAndroidChannel = "stable",
|
||||
[string]$ExpectedAndroidPlatform = "android",
|
||||
[string]$ExpectedAndroidPackageKind = "apk",
|
||||
[string]$ExpectedAndroidSha256 = "872959380cfbfd858c11f388d430f8ce82ae8a9289a1e40bc8fa5b8d7c3897d8",
|
||||
[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."
|
||||
Reference in New Issue
Block a user