Implement background media ingestion and Pi deployment
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: gradle
|
||||||
|
directory: /Android
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
labels:
|
||||||
|
- Dependencies
|
||||||
|
commit-message:
|
||||||
|
prefix: feat
|
||||||
|
|
||||||
|
- package-ecosystem: gomod
|
||||||
|
directory: /Server
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
open-pull-requests-limit: 2
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /Server/services/idp
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 2
|
||||||
|
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: /Server
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Android
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- Android/**
|
||||||
|
- .github/workflows/android.yml
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- Android/**
|
||||||
|
- .github/workflows/android.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: Android
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: ./gradlew :qsferaComLibrary:testDebugUnitTest :qsferaDomain:testDebugUnitTest :qsferaData:testDebugUnitTest :qsferaApp:testOriginalDebugUnitTest --no-daemon
|
||||||
|
|
||||||
|
- name: Build original debug APK
|
||||||
|
run: ./gradlew :qsferaApp:assembleOriginalDebug --no-daemon
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: Server
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- Server/**
|
||||||
|
- .github/workflows/server.yml
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- Server/**
|
||||||
|
- .github/workflows/server.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: server-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deployment-config:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Check deployment scripts
|
||||||
|
run: sh -n Server/deployments/raspberry-pi/backup.sh Server/deployments/raspberry-pi/restore.sh Server/deployments/raspberry-pi/smoke-test.sh
|
||||||
|
|
||||||
|
- name: Validate Compose model
|
||||||
|
env:
|
||||||
|
QSFERA_IMAGE: example.invalid/qsfera:test
|
||||||
|
QSFERA_URL: https://cloud.example.invalid
|
||||||
|
QSFERA_ADMIN_PASSWORD: ci-only-placeholder
|
||||||
|
QSFERA_CONFIG_DIR: /tmp/qsfera-config
|
||||||
|
QSFERA_DATA_DIR: /tmp/qsfera-data
|
||||||
|
QSFERA_MEMORY_LIMIT: 1g
|
||||||
|
run: docker compose -f Server/deployments/raspberry-pi/compose.yaml config --quiet
|
||||||
|
|
||||||
|
vulnerability-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: Server
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Go from go.mod
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: Server/go.mod
|
||||||
|
cache-dependency-path: Server/go.sum
|
||||||
|
|
||||||
|
- name: Install native image dependency
|
||||||
|
run: sudo apt-get update && sudo apt-get install --yes libvips-dev
|
||||||
|
|
||||||
|
- name: Run pinned govulncheck
|
||||||
|
run: make govulncheck
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
# Local configuration files (sdk path, etc)
|
# Local configuration files (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
# Local release signing material
|
||||||
|
signing/*.jks
|
||||||
|
signing/*-signing.local.properties
|
||||||
|
|
||||||
# Mac .DS_Store files
|
# Mac .DS_Store files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ def envValue(String primary, String legacy = null) {
|
|||||||
return legacy ? System.getenv(legacy) : null
|
return legacy ? System.getenv(legacy) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def releaseSigningProperties = new Properties()
|
||||||
|
def releaseSigningPropertiesFile = rootProject.file('signing/qsfera-signing.local.properties')
|
||||||
|
if (releaseSigningPropertiesFile.exists()) {
|
||||||
|
releaseSigningPropertiesFile.withInputStream { stream ->
|
||||||
|
releaseSigningProperties.load(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def signingValue = { String primary, String legacy = null ->
|
||||||
|
envValue(primary, legacy) ?:
|
||||||
|
releaseSigningProperties.getProperty(primary) ?:
|
||||||
|
(legacy ? releaseSigningProperties.getProperty(legacy) : null)
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Data and domain modules
|
// Data and domain modules
|
||||||
implementation project(':qsferaDomain')
|
implementation project(':qsferaDomain')
|
||||||
@@ -162,12 +176,12 @@ android {
|
|||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
def releaseKeystore = envValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')
|
def releaseKeystore = signingValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')
|
||||||
if (releaseKeystore) {
|
if (releaseKeystore) {
|
||||||
storeFile file(releaseKeystore) // use an absolute path
|
storeFile file(releaseKeystore) // use an absolute path
|
||||||
storePassword envValue('QSFERA_RELEASE_KEYSTORE_PASSWORD', 'OC_RELEASE_KEYSTORE_PASSWORD')
|
storePassword signingValue('QSFERA_RELEASE_KEYSTORE_PASSWORD', 'OC_RELEASE_KEYSTORE_PASSWORD')
|
||||||
keyAlias envValue('QSFERA_RELEASE_KEY_ALIAS', 'OC_RELEASE_KEY_ALIAS')
|
keyAlias signingValue('QSFERA_RELEASE_KEY_ALIAS', 'OC_RELEASE_KEY_ALIAS')
|
||||||
keyPassword envValue('QSFERA_RELEASE_KEY_PASSWORD', 'OC_RELEASE_KEY_PASSWORD')
|
keyPassword signingValue('QSFERA_RELEASE_KEY_PASSWORD', 'OC_RELEASE_KEY_PASSWORD')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +189,7 @@ android {
|
|||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
||||||
release {
|
release {
|
||||||
if (envValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')) {
|
if (signingValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')) {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import android.view.WindowManager
|
|||||||
import android.widget.CheckBox
|
import android.widget.CheckBox
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.content.pm.PackageInfoCompat
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
|
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ import eu.qsfera.android.dependecyinjection.remoteDataSourceModule
|
|||||||
import eu.qsfera.android.dependecyinjection.repositoryModule
|
import eu.qsfera.android.dependecyinjection.repositoryModule
|
||||||
import eu.qsfera.android.dependecyinjection.useCaseModule
|
import eu.qsfera.android.dependecyinjection.useCaseModule
|
||||||
import eu.qsfera.android.dependecyinjection.viewModelModule
|
import eu.qsfera.android.dependecyinjection.viewModelModule
|
||||||
|
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||||
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
|
||||||
import eu.qsfera.android.domain.spaces.model.OCSpace
|
import eu.qsfera.android.domain.spaces.model.OCSpace
|
||||||
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
|
||||||
@@ -123,6 +125,7 @@ class MainApp : Application() {
|
|||||||
initDependencyInjection()
|
initDependencyInjection()
|
||||||
|
|
||||||
val workManagerProvider: WorkManagerProvider by inject()
|
val workManagerProvider: WorkManagerProvider by inject()
|
||||||
|
val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase by inject()
|
||||||
var startedActivities = 0
|
var startedActivities = 0
|
||||||
|
|
||||||
// register global protection with pass code, pattern lock and biometric lock
|
// register global protection with pass code, pattern lock and biometric lock
|
||||||
@@ -228,8 +231,15 @@ class MainApp : Application() {
|
|||||||
// (recovers if the chain was dropped) and trigger an immediate scan
|
// (recovers if the chain was dropped) and trigger an immediate scan
|
||||||
// so the user doesn't have to wait up to 15 min.
|
// so the user doesn't have to wait up to 15 min.
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val sourcePaths = getAutomaticUploadsConfigurationUseCase(Unit)
|
||||||
|
.getDataOrNull()
|
||||||
|
?.sourcePaths
|
||||||
|
.orEmpty()
|
||||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||||
|
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||||
|
sourcePaths = sourcePaths,
|
||||||
|
)
|
||||||
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
|
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-18
@@ -41,14 +41,12 @@ import androidx.preference.PreferenceFragmentCompat
|
|||||||
import androidx.preference.SwitchPreferenceCompat
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
import eu.qsfera.android.R
|
import eu.qsfera.android.R
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR
|
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ENABLED
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_ENABLED
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_PATH
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_PATH
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_SOURCE
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_SOURCE
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
|
||||||
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
|
import eu.qsfera.android.extensions.collectLatestLifecycleFlow
|
||||||
import eu.qsfera.android.extensions.showAlertDialog
|
import eu.qsfera.android.extensions.showAlertDialog
|
||||||
import eu.qsfera.android.extensions.showMessageInSnackbar
|
import eu.qsfera.android.extensions.showMessageInSnackbar
|
||||||
@@ -71,7 +69,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
|
private var prefPictureUploadsOnCharging: CheckBoxPreference? = null
|
||||||
private var prefPictureUploadsSourcePath: Preference? = null
|
private var prefPictureUploadsSourcePath: Preference? = null
|
||||||
private var prefPictureUploadsClearSourcePaths: Preference? = null
|
private var prefPictureUploadsClearSourcePaths: Preference? = null
|
||||||
private var prefPictureUploadsBehaviour: ListPreference? = null
|
|
||||||
private var prefPictureUploadsAccount: ListPreference? = null
|
private var prefPictureUploadsAccount: ListPreference? = null
|
||||||
private var prefPictureUploadsLastSync: Preference? = null
|
private var prefPictureUploadsLastSync: Preference? = null
|
||||||
private var spaceId: String? = null
|
private var spaceId: String? = null
|
||||||
@@ -104,13 +101,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
|
prefPictureUploadsSourcePath = findPreference(PREF__CAMERA_PICTURE_UPLOADS_SOURCE)
|
||||||
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
|
prefPictureUploadsClearSourcePaths = findPreference(PREF_PICTURE_UPLOADS_CLEAR_SOURCE_PATHS)
|
||||||
prefPictureUploadsLastSync = findPreference(PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC)
|
prefPictureUploadsLastSync = findPreference(PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC)
|
||||||
prefPictureUploadsBehaviour = findPreference<ListPreference>(PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR)?.apply {
|
|
||||||
entries = listOf(
|
|
||||||
getString(R.string.pref_behaviour_entries_keep_file),
|
|
||||||
getString(R.string.pref_behaviour_entries_remove_original_file)
|
|
||||||
).toTypedArray()
|
|
||||||
entryValues = listOf(UploadBehavior.COPY.name, UploadBehavior.MOVE.name).toTypedArray()
|
|
||||||
}
|
|
||||||
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
|
prefPictureUploadsAccount = findPreference(PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME)
|
||||||
|
|
||||||
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
|
val comment = getString(R.string.prefs_camera_upload_source_path_title_required)
|
||||||
@@ -158,7 +148,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
prefPictureUploadsClearSourcePaths?.isEnabled = sourcePaths.isNotEmpty()
|
||||||
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
|
prefPictureUploadsOnWifi?.isChecked = it.wifiOnly
|
||||||
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
|
prefPictureUploadsOnCharging?.isChecked = it.chargingOnly
|
||||||
prefPictureUploadsBehaviour?.value = it.behavior.name
|
|
||||||
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
|
prefPictureUploadsLastSync?.summary = DisplayUtils.unixTimeToHumanReadable(it.lastSyncTimestamp)
|
||||||
spaceId = it.spaceId
|
spaceId = it.spaceId
|
||||||
} ?: resetFields()
|
} ?: resetFields()
|
||||||
@@ -258,11 +247,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
prefPictureUploadsBehaviour?.setOnPreferenceChangeListener { _, newValue ->
|
|
||||||
newValue as String
|
|
||||||
picturesViewModel.handleSelectBehaviour(newValue)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -280,7 +264,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefPictureUploadsOnCharging?.isEnabled = value
|
prefPictureUploadsOnCharging?.isEnabled = value
|
||||||
prefPictureUploadsSourcePath?.isEnabled = value
|
prefPictureUploadsSourcePath?.isEnabled = value
|
||||||
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
|
prefPictureUploadsClearSourcePaths?.isEnabled = value && picturesViewModel.getPictureUploadsSourcePaths().isNotEmpty()
|
||||||
prefPictureUploadsBehaviour?.isEnabled = value
|
|
||||||
prefPictureUploadsAccount?.isEnabled = value
|
prefPictureUploadsAccount?.isEnabled = value
|
||||||
prefPictureUploadsLastSync?.isEnabled = value
|
prefPictureUploadsLastSync?.isEnabled = value
|
||||||
}
|
}
|
||||||
@@ -292,7 +275,6 @@ class SettingsPictureUploadsFragment : PreferenceFragmentCompat() {
|
|||||||
prefPictureUploadsClearSourcePaths?.isEnabled = false
|
prefPictureUploadsClearSourcePaths?.isEnabled = false
|
||||||
prefPictureUploadsOnWifi?.isChecked = false
|
prefPictureUploadsOnWifi?.isChecked = false
|
||||||
prefPictureUploadsOnCharging?.isChecked = false
|
prefPictureUploadsOnCharging?.isChecked = false
|
||||||
prefPictureUploadsBehaviour?.value = UploadBehavior.COPY.name
|
|
||||||
prefPictureUploadsLastSync?.summary = null
|
prefPictureUploadsLastSync?.summary = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-14
@@ -26,12 +26,14 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
import eu.qsfera.android.R
|
import eu.qsfera.android.R
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||||
|
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
|
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.ResetPictureUploadsUseCase
|
import eu.qsfera.android.domain.automaticuploads.usecases.ResetPictureUploadsUseCase
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase
|
import eu.qsfera.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase
|
||||||
@@ -53,6 +55,7 @@ import timber.log.Timber
|
|||||||
class SettingsPictureUploadsViewModel(
|
class SettingsPictureUploadsViewModel(
|
||||||
private val accountProvider: AccountProvider,
|
private val accountProvider: AccountProvider,
|
||||||
private val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase,
|
private val savePictureUploadsConfigurationUseCase: SavePictureUploadsConfigurationUseCase,
|
||||||
|
private val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase,
|
||||||
private val getPictureUploadsConfigurationStreamUseCase: GetPictureUploadsConfigurationStreamUseCase,
|
private val getPictureUploadsConfigurationStreamUseCase: GetPictureUploadsConfigurationStreamUseCase,
|
||||||
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
|
private val resetPictureUploadsUseCase: ResetPictureUploadsUseCase,
|
||||||
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
||||||
@@ -161,16 +164,6 @@ class SettingsPictureUploadsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleSelectBehaviour(behaviorString: String) {
|
|
||||||
val behavior = UploadBehavior.fromString(behaviorString)
|
|
||||||
|
|
||||||
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
|
||||||
savePictureUploadsConfigurationUseCase(
|
|
||||||
SavePictureUploadsConfigurationUseCase.Params(composePictureUploadsConfiguration(behavior = behavior))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleSelectPictureUploadsSourcePath(contentUriForTree: Uri) {
|
fun handleSelectPictureUploadsSourcePath(contentUriForTree: Uri) {
|
||||||
val previousSourcePaths = getPictureUploadsSourcePaths()
|
val previousSourcePaths = getPictureUploadsSourcePaths()
|
||||||
val newSourcePath = contentUriForTree.toString()
|
val newSourcePath = contentUriForTree.toString()
|
||||||
@@ -202,8 +195,14 @@ class SettingsPictureUploadsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun schedulePictureUploads() {
|
fun schedulePictureUploads() {
|
||||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
val sourcePaths = getAutomaticUploadsConfigurationUseCase(Unit).getDataOrNull()?.sourcePaths.orEmpty()
|
||||||
|
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||||
|
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||||
|
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||||
|
sourcePaths = sourcePaths,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun composePictureUploadsConfiguration(
|
private fun composePictureUploadsConfiguration(
|
||||||
@@ -212,12 +211,11 @@ class SettingsPictureUploadsViewModel(
|
|||||||
wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly,
|
wifiOnly: Boolean? = _pictureUploads.value?.wifiOnly,
|
||||||
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
|
chargingOnly: Boolean? = _pictureUploads.value?.chargingOnly,
|
||||||
sourcePath: String? = _pictureUploads.value?.sourcePath,
|
sourcePath: String? = _pictureUploads.value?.sourcePath,
|
||||||
behavior: UploadBehavior? = _pictureUploads.value?.behavior,
|
|
||||||
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
|
timestamp: Long? = _pictureUploads.value?.lastSyncTimestamp,
|
||||||
spaceId: String? = _pictureUploads.value?.spaceId,
|
spaceId: String? = _pictureUploads.value?.spaceId,
|
||||||
): FolderBackUpConfiguration = FolderBackUpConfiguration(
|
): FolderBackUpConfiguration = FolderBackUpConfiguration(
|
||||||
accountName = accountName ?: accountProvider.getCurrentQSferaAccount()!!.name,
|
accountName = accountName ?: accountProvider.getCurrentQSferaAccount()!!.name,
|
||||||
behavior = behavior ?: UploadBehavior.COPY,
|
behavior = UploadBehavior.MOVE,
|
||||||
sourcePath = sourcePath.orEmpty(),
|
sourcePath = sourcePath.orEmpty(),
|
||||||
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
|
||||||
wifiOnly = wifiOnly ?: false,
|
wifiOnly = wifiOnly ?: false,
|
||||||
|
|||||||
+11
-2
@@ -26,12 +26,14 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
import eu.qsfera.android.R
|
import eu.qsfera.android.R
|
||||||
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
|
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
|
||||||
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
|
||||||
|
import eu.qsfera.android.domain.automaticuploads.usecases.GetAutomaticUploadsConfigurationUseCase
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
|
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.ResetVideoUploadsUseCase
|
import eu.qsfera.android.domain.automaticuploads.usecases.ResetVideoUploadsUseCase
|
||||||
import eu.qsfera.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase
|
import eu.qsfera.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase
|
||||||
@@ -53,6 +55,7 @@ import timber.log.Timber
|
|||||||
class SettingsVideoUploadsViewModel(
|
class SettingsVideoUploadsViewModel(
|
||||||
private val accountProvider: AccountProvider,
|
private val accountProvider: AccountProvider,
|
||||||
private val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase,
|
private val saveVideoUploadsConfigurationUseCase: SaveVideoUploadsConfigurationUseCase,
|
||||||
|
private val getAutomaticUploadsConfigurationUseCase: GetAutomaticUploadsConfigurationUseCase,
|
||||||
private val getVideoUploadsConfigurationStreamUseCase: GetVideoUploadsConfigurationStreamUseCase,
|
private val getVideoUploadsConfigurationStreamUseCase: GetVideoUploadsConfigurationStreamUseCase,
|
||||||
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
|
private val resetVideoUploadsUseCase: ResetVideoUploadsUseCase,
|
||||||
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
private val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase,
|
||||||
@@ -202,8 +205,14 @@ class SettingsVideoUploadsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun scheduleVideoUploads() {
|
fun scheduleVideoUploads() {
|
||||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
viewModelScope.launch(coroutinesDispatcherProvider.io) {
|
||||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
val sourcePaths = getAutomaticUploadsConfigurationUseCase(Unit).getDataOrNull()?.sourcePaths.orEmpty()
|
||||||
|
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||||
|
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||||
|
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||||
|
sourcePaths = sourcePaths,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun composeVideoUploadsConfiguration(
|
private fun composeVideoUploadsConfiguration(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import android.net.Uri
|
|||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import eu.qsfera.android.MainApp
|
import eu.qsfera.android.MainApp
|
||||||
import eu.qsfera.android.R
|
import eu.qsfera.android.R
|
||||||
@@ -994,8 +995,14 @@ class FileContentProvider(val executors: Executors = Executors()) : ContentProvi
|
|||||||
videoUploadsConfiguration?.let { backupLocalDataSource.saveFolderBackupConfiguration(it) }
|
videoUploadsConfiguration?.let { backupLocalDataSource.saveFolderBackupConfiguration(it) }
|
||||||
if (pictureUploadsConfiguration != null || videoUploadsConfiguration != null) {
|
if (pictureUploadsConfiguration != null || videoUploadsConfiguration != null) {
|
||||||
val workManagerProvider = WorkManagerProvider(context!!)
|
val workManagerProvider = WorkManagerProvider(context!!)
|
||||||
|
val sourcePaths = listOfNotNull(pictureUploadsConfiguration, videoUploadsConfiguration)
|
||||||
|
.flatMap { it.sourcePaths }
|
||||||
|
.distinct()
|
||||||
workManagerProvider.enqueueAutomaticUploadsWorker()
|
workManagerProvider.enqueueAutomaticUploadsWorker()
|
||||||
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
|
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker(
|
||||||
|
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
|
||||||
|
sourcePaths = sourcePaths,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|||||||
@@ -21,8 +21,12 @@
|
|||||||
|
|
||||||
package eu.qsfera.android.providers
|
package eu.qsfera.android.providers
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
@@ -60,14 +64,21 @@ class WorkManagerProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun enqueueMediaStoreAutomaticUploadsWorker(
|
fun enqueueMediaStoreAutomaticUploadsWorker(
|
||||||
existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP
|
existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP,
|
||||||
|
sourcePaths: Collection<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val mediaStoreTriggers = Constraints.Builder()
|
val constraintsBuilder = Constraints.Builder()
|
||||||
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
|
||||||
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
|
||||||
.setTriggerContentUpdateDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
|
.setTriggerContentUpdateDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
|
||||||
.setTriggerContentMaxDelay(AutomaticUploadsWorker.MEDIA_STORE_TRIGGER_MAX_DELAY_MS, TimeUnit.MILLISECONDS)
|
.setTriggerContentMaxDelay(AutomaticUploadsWorker.MEDIA_STORE_TRIGGER_MAX_DELAY_MS, TimeUnit.MILLISECONDS)
|
||||||
.build()
|
|
||||||
|
sourcePaths
|
||||||
|
.flatMap(::sourcePathTriggerUris)
|
||||||
|
.distinct()
|
||||||
|
.forEach { constraintsBuilder.addContentUriTrigger(it, true) }
|
||||||
|
|
||||||
|
val mediaStoreTriggers = constraintsBuilder.build()
|
||||||
|
|
||||||
val mediaStoreWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
|
val mediaStoreWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
|
||||||
.addTag(AutomaticUploadsWorker.MEDIA_STORE_UPLOADS_WORKER)
|
.addTag(AutomaticUploadsWorker.MEDIA_STORE_UPLOADS_WORKER)
|
||||||
@@ -84,6 +95,24 @@ class WorkManagerProvider(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sourcePathTriggerUris(sourcePath: String): List<Uri> {
|
||||||
|
val treeUri = sourcePath.trim().takeIf { it.isNotEmpty() }?.toUri() ?: return emptyList()
|
||||||
|
if (treeUri.scheme != ContentResolver.SCHEME_CONTENT) return emptyList()
|
||||||
|
|
||||||
|
val documentUri = runCatching {
|
||||||
|
if (DocumentsContract.isTreeUri(treeUri)) {
|
||||||
|
DocumentsContract.buildDocumentUriUsingTree(
|
||||||
|
treeUri,
|
||||||
|
DocumentsContract.getTreeDocumentId(treeUri),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
return listOfNotNull(treeUri, documentUri)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger an immediate one-time upload scan, e.g. when the app enters foreground.
|
* Trigger an immediate one-time upload scan, e.g. when the app enters foreground.
|
||||||
* Skips if either the periodic or immediate worker is already running to avoid
|
* Skips if either the periodic or immediate worker is already running to avoid
|
||||||
|
|||||||
+5
@@ -83,6 +83,11 @@ class UploadFileFromContentUriUseCase(
|
|||||||
if (behavior == UploadBehavior.MOVE) {
|
if (behavior == UploadBehavior.MOVE) {
|
||||||
val removeSourceFileWorker = OneTimeWorkRequestBuilder<RemoveSourceFileWorker>()
|
val removeSourceFileWorker = OneTimeWorkRequestBuilder<RemoveSourceFileWorker>()
|
||||||
.setInputData(inputDataRemoveSourceFileWorker)
|
.setInputData(inputDataRemoveSourceFileWorker)
|
||||||
|
.setBackoffCriteria(
|
||||||
|
BackoffPolicy.EXPONENTIAL,
|
||||||
|
10,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
workManager.beginUniqueWork(
|
workManager.beginUniqueWork(
|
||||||
uniqueWorkName,
|
uniqueWorkName,
|
||||||
|
|||||||
+63
-7
@@ -79,6 +79,7 @@ class AutomaticUploadsWorker(
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
Timber.i("Starting AutomaticUploadsWorker with UUID ${this.id}")
|
Timber.i("Starting AutomaticUploadsWorker with UUID ${this.id}")
|
||||||
var automaticUploadsEnabled = true
|
var automaticUploadsEnabled = true
|
||||||
|
var configuredSourcePaths: List<String> = emptyList()
|
||||||
when (val useCaseResult = getAutomaticUploadsConfigurationUseCase(Unit)) {
|
when (val useCaseResult = getAutomaticUploadsConfigurationUseCase(Unit)) {
|
||||||
is UseCaseResult.Success -> {
|
is UseCaseResult.Success -> {
|
||||||
val cameraUploadsConfiguration = useCaseResult.data
|
val cameraUploadsConfiguration = useCaseResult.data
|
||||||
@@ -86,6 +87,7 @@ class AutomaticUploadsWorker(
|
|||||||
cancelWorker()
|
cancelWorker()
|
||||||
automaticUploadsEnabled = false
|
automaticUploadsEnabled = false
|
||||||
} else {
|
} else {
|
||||||
|
configuredSourcePaths = cameraUploadsConfiguration.sourcePaths
|
||||||
cameraUploadsConfiguration.pictureUploadsConfiguration?.let { pictureUploadsConfiguration ->
|
cameraUploadsConfiguration.pictureUploadsConfiguration?.let { pictureUploadsConfiguration ->
|
||||||
try {
|
try {
|
||||||
checkSourcePathsAreValidUrisOrThrowException(pictureUploadsConfiguration.sourcePaths)
|
checkSourcePathsAreValidUrisOrThrowException(pictureUploadsConfiguration.sourcePaths)
|
||||||
@@ -110,17 +112,21 @@ class AutomaticUploadsWorker(
|
|||||||
Timber.e(useCaseResult.throwable, "Worker ${useCaseResult.throwable}")
|
Timber.e(useCaseResult.throwable, "Worker ${useCaseResult.throwable}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled)
|
rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled, configuredSourcePaths)
|
||||||
Timber.i("Finishing CameraUploadsWorker with UUID ${this.id}")
|
Timber.i("Finishing CameraUploadsWorker with UUID ${this.id}")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rescheduleMediaStoreTriggerIfNeeded(automaticUploadsEnabled: Boolean) {
|
private fun rescheduleMediaStoreTriggerIfNeeded(
|
||||||
|
automaticUploadsEnabled: Boolean,
|
||||||
|
sourcePaths: List<String>,
|
||||||
|
) {
|
||||||
val shouldReschedule = inputData.getBoolean(KEY_PARAM_RESCHEDULE_MEDIA_STORE_TRIGGER, false)
|
val shouldReschedule = inputData.getBoolean(KEY_PARAM_RESCHEDULE_MEDIA_STORE_TRIGGER, false)
|
||||||
if (!shouldReschedule || !automaticUploadsEnabled) return
|
if (!shouldReschedule || !automaticUploadsEnabled) return
|
||||||
|
|
||||||
WorkManagerProvider(appContext).enqueueMediaStoreAutomaticUploadsWorker(
|
WorkManagerProvider(appContext).enqueueMediaStoreAutomaticUploadsWorker(
|
||||||
existingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE
|
existingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||||
|
sourcePaths = sourcePaths,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +152,30 @@ class AutomaticUploadsWorker(
|
|||||||
// Else should not happen for the moment. Maybe in upcoming features..
|
// Else should not happen for the moment. Maybe in upcoming features..
|
||||||
else -> SyncType.PICTURE_UPLOADS
|
else -> SyncType.PICTURE_UPLOADS
|
||||||
}
|
}
|
||||||
|
val effectiveBehavior = folderBackUpConfiguration.effectiveBehavior
|
||||||
|
|
||||||
val currentTimestamp = System.currentTimeMillis()
|
val currentTimestamp = System.currentTimeMillis()
|
||||||
|
val automaticUploadSourceType = when (syncType) {
|
||||||
|
SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE
|
||||||
|
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
|
||||||
|
}
|
||||||
|
val completedUploadTimesBySourceUri = if (effectiveBehavior == UploadBehavior.MOVE) {
|
||||||
|
transferRepository.getFinishedTransfers()
|
||||||
|
.asSequence()
|
||||||
|
.filter {
|
||||||
|
it.createdBy == automaticUploadSourceType &&
|
||||||
|
it.accountName == folderBackUpConfiguration.accountName
|
||||||
|
}
|
||||||
|
.mapNotNull { transfer ->
|
||||||
|
val sourcePath = transfer.sourcePath
|
||||||
|
val completedAt = transfer.transferEndTimestamp
|
||||||
|
if (sourcePath != null && completedAt != null) sourcePath to completedAt else null
|
||||||
|
}
|
||||||
|
.groupBy(keySelector = { it.first }, valueTransform = { it.second })
|
||||||
|
.mapValues { (_, completionTimes) -> completionTimes.maxOrNull()!! }
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
|
val localPicturesDocumentFiles: List<DocumentFile> = folderBackUpConfiguration.sourcePaths.flatMap { sourcePath ->
|
||||||
getFilesReadyToUpload(
|
getFilesReadyToUpload(
|
||||||
@@ -155,6 +183,7 @@ class AutomaticUploadsWorker(
|
|||||||
sourcePath = sourcePath,
|
sourcePath = sourcePath,
|
||||||
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
|
lastSyncTimestamp = folderBackUpConfiguration.lastSyncTimestamp,
|
||||||
currentTimestamp = currentTimestamp,
|
currentTimestamp = currentTimestamp,
|
||||||
|
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +204,7 @@ class AutomaticUploadsWorker(
|
|||||||
documentFile = documentFile,
|
documentFile = documentFile,
|
||||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
||||||
accountName = folderBackUpConfiguration.accountName,
|
accountName = folderBackUpConfiguration.accountName,
|
||||||
behavior = folderBackUpConfiguration.behavior,
|
behavior = effectiveBehavior,
|
||||||
createdByWorker = when (syncType) {
|
createdByWorker = when (syncType) {
|
||||||
SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE
|
SyncType.PICTURE_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_PICTURE
|
||||||
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
|
SyncType.VIDEO_UPLOADS -> UploadEnqueuedBy.ENQUEUED_AS_AUTOMATIC_UPLOAD_VIDEO
|
||||||
@@ -186,7 +215,7 @@ class AutomaticUploadsWorker(
|
|||||||
contentUri = documentFile.uri,
|
contentUri = documentFile.uri,
|
||||||
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
uploadPath = folderBackUpConfiguration.uploadPath.plus(File.separator).plus(documentFile.name),
|
||||||
lastModified = documentFile.lastModified(),
|
lastModified = documentFile.lastModified(),
|
||||||
behavior = folderBackUpConfiguration.behavior.toString(),
|
behavior = effectiveBehavior.toString(),
|
||||||
accountName = folderBackUpConfiguration.accountName,
|
accountName = folderBackUpConfiguration.accountName,
|
||||||
uploadId = uploadId,
|
uploadId = uploadId,
|
||||||
wifiOnly = folderBackUpConfiguration.wifiOnly,
|
wifiOnly = folderBackUpConfiguration.wifiOnly,
|
||||||
@@ -272,6 +301,7 @@ class AutomaticUploadsWorker(
|
|||||||
sourcePath: String,
|
sourcePath: String,
|
||||||
lastSyncTimestamp: Long,
|
lastSyncTimestamp: Long,
|
||||||
currentTimestamp: Long,
|
currentTimestamp: Long,
|
||||||
|
completedUploadTimesBySourceUri: Map<String, Long>,
|
||||||
): List<DocumentFile> {
|
): List<DocumentFile> {
|
||||||
val sourceUri: Uri = sourcePath.toUri()
|
val sourceUri: Uri = sourcePath.toUri()
|
||||||
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
|
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
|
||||||
@@ -282,11 +312,28 @@ class AutomaticUploadsWorker(
|
|||||||
// can result in uploading a truncated or 0-byte JPEG.
|
// can result in uploading a truncated or 0-byte JPEG.
|
||||||
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
val safeTimestamp = currentTimestamp - WRITE_SAFETY_BUFFER_MS
|
||||||
|
|
||||||
val filteredList: List<DocumentFile> = arrayOfLocalFiles
|
val mediaFiles = arrayOfLocalFiles
|
||||||
.sortedBy { it.lastModified() }
|
.sortedBy { it.lastModified() }
|
||||||
|
.filter { MimetypeIconUtil.getBestMimeTypeByFilename(it.name).startsWith(syncType.prefixForType) }
|
||||||
|
|
||||||
|
val previouslyUploadedFiles = mediaFiles.filter { documentFile ->
|
||||||
|
shouldRemovePreviouslyUploadedSource(
|
||||||
|
sourceUri = documentFile.uri.toString(),
|
||||||
|
lastModified = documentFile.lastModified(),
|
||||||
|
completedUploadTimesBySourceUri = completedUploadTimesBySourceUri,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
previouslyUploadedFiles.forEach { documentFile ->
|
||||||
|
if (!removeSourceDocument(documentFile)) {
|
||||||
|
Timber.w("Uploaded source file could not be removed yet: %s", documentFile.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val previouslyUploadedUris = previouslyUploadedFiles.mapTo(mutableSetOf()) { it.uri }
|
||||||
|
|
||||||
|
val filteredList: List<DocumentFile> = mediaFiles
|
||||||
|
.filterNot { it.uri in previouslyUploadedUris }
|
||||||
.filter { it.lastModified() >= lastSyncTimestamp }
|
.filter { it.lastModified() >= lastSyncTimestamp }
|
||||||
.filter { it.lastModified() < safeTimestamp }
|
.filter { it.lastModified() < safeTimestamp }
|
||||||
.filter { MimetypeIconUtil.getBestMimeTypeByFilename(it.name).startsWith(syncType.prefixForType) }
|
|
||||||
|
|
||||||
Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
|
Timber.i("Last sync ${syncType.name}: ${Date(lastSyncTimestamp)}")
|
||||||
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
|
Timber.i("CurrentTimestamp ${Date(currentTimestamp)}")
|
||||||
@@ -359,3 +406,12 @@ class AutomaticUploadsWorker(
|
|||||||
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L
|
const val MEDIA_STORE_TRIGGER_MAX_DELAY_MS = 60_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun shouldRemovePreviouslyUploadedSource(
|
||||||
|
sourceUri: String,
|
||||||
|
lastModified: Long,
|
||||||
|
completedUploadTimesBySourceUri: Map<String, Long>,
|
||||||
|
): Boolean {
|
||||||
|
val completedAt = completedUploadTimesBySourceUri[sourceUri] ?: return false
|
||||||
|
return lastModified > 0 && lastModified <= completedAt
|
||||||
|
}
|
||||||
|
|||||||
+14
-3
@@ -44,11 +44,15 @@ class RemoveSourceFileWorker(
|
|||||||
if (!areParametersValid()) return Result.failure()
|
if (!areParametersValid()) return Result.failure()
|
||||||
return try {
|
return try {
|
||||||
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
|
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
|
||||||
documentFile?.delete()
|
if (removeSourceDocument(documentFile)) {
|
||||||
Result.success()
|
Result.success()
|
||||||
|
} else {
|
||||||
|
Timber.w("Source file could not be removed yet: %s", contentUri)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
} catch (throwable: Throwable) {
|
} catch (throwable: Throwable) {
|
||||||
Timber.e(throwable)
|
Timber.e(throwable)
|
||||||
Result.failure()
|
Result.retry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,3 +64,10 @@ class RemoveSourceFileWorker(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun removeSourceDocument(documentFile: DocumentFile?): Boolean {
|
||||||
|
if (documentFile == null) return false
|
||||||
|
if (!documentFile.exists()) return true
|
||||||
|
|
||||||
|
return documentFile.delete() || !documentFile.exists()
|
||||||
|
}
|
||||||
|
|||||||
@@ -568,7 +568,7 @@
|
|||||||
<string name="upload_copy_files">Скопировать файл</string>
|
<string name="upload_copy_files">Скопировать файл</string>
|
||||||
<string name="upload_move_files">Переместить файл</string>
|
<string name="upload_move_files">Переместить файл</string>
|
||||||
<string name="pref_behaviour_entries_keep_file">остался в исходной папке</string>
|
<string name="pref_behaviour_entries_keep_file">остался в исходной папке</string>
|
||||||
<string name="pref_behaviour_entries_remove_original_file">удалено из исходной папки</string>
|
<string name="pref_behaviour_entries_remove_original_file">удалён из исходной папки</string>
|
||||||
<string name="share_dialog_title">Общий доступ</string>
|
<string name="share_dialog_title">Общий доступ</string>
|
||||||
<string name="share_file">Поделиться %1$s</string>
|
<string name="share_file">Поделиться %1$s</string>
|
||||||
<string name="share_with_user_section_title">Пользователи и группы</string>
|
<string name="share_with_user_section_title">Пользователи и группы</string>
|
||||||
|
|||||||
@@ -44,14 +44,11 @@
|
|||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
app:key="picture_uploads_clear_source_paths"
|
app:key="picture_uploads_clear_source_paths"
|
||||||
app:title="@string/prefs_camera_upload_source_paths_clear_title" />
|
app:title="@string/prefs_camera_upload_source_paths_clear_title" />
|
||||||
<ListPreference
|
<Preference
|
||||||
app:defaultValue="NOTHING"
|
|
||||||
app:dialogTitle="@string/prefs_camera_upload_behaviour_dialog_title"
|
|
||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
app:key="picture_uploads_behaviour"
|
app:selectable="false"
|
||||||
app:negativeButtonText=""
|
app:summary="@string/pref_behaviour_entries_remove_original_file"
|
||||||
app:title="@string/prefs_camera_upload_behaviour_title"
|
app:title="@string/prefs_camera_upload_behaviour_title" />
|
||||||
app:useSimpleSummaryProvider="true" />
|
|
||||||
<Preference
|
<Preference
|
||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
app:key="picture_uploads_last_sync"
|
app:key="picture_uploads_last_sync"
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* qsfera Android client application
|
||||||
|
*
|
||||||
|
* Copyright (C) 2026 QSfera.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License version 2,
|
||||||
|
* as published by the Free Software Foundation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.qsfera.android.workers
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AutomaticUploadsWorkerTest {
|
||||||
|
|
||||||
|
private val sourceUri = "content://camera/photo.jpg"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `completed upload identifies original source`() {
|
||||||
|
assertTrue(
|
||||||
|
shouldRemovePreviouslyUploadedSource(
|
||||||
|
sourceUri = sourceUri,
|
||||||
|
lastModified = 1_000,
|
||||||
|
completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new file reusing uri is not treated as uploaded source`() {
|
||||||
|
assertFalse(
|
||||||
|
shouldRemovePreviouslyUploadedSource(
|
||||||
|
sourceUri = sourceUri,
|
||||||
|
lastModified = 3_000,
|
||||||
|
completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown modification time is not deleted`() {
|
||||||
|
assertFalse(
|
||||||
|
shouldRemovePreviouslyUploadedSource(
|
||||||
|
sourceUri = sourceUri,
|
||||||
|
lastModified = 0,
|
||||||
|
completedUploadTimesBySourceUri = mapOf(sourceUri to 2_000),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* qsfera Android client application
|
||||||
|
*
|
||||||
|
* Copyright (C) 2026 QSfera.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License version 2,
|
||||||
|
* as published by the Free Software Foundation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.qsfera.android.workers
|
||||||
|
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RemoveSourceFileWorkerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing source is already removed`() {
|
||||||
|
val documentFile = mockk<DocumentFile>()
|
||||||
|
every { documentFile.exists() } returns false
|
||||||
|
|
||||||
|
assertTrue(removeSourceDocument(documentFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `successful delete removes source`() {
|
||||||
|
val documentFile = mockk<DocumentFile>()
|
||||||
|
every { documentFile.exists() } returns true
|
||||||
|
every { documentFile.delete() } returns true
|
||||||
|
|
||||||
|
assertTrue(removeSourceDocument(documentFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed delete requests another attempt`() {
|
||||||
|
val documentFile = mockk<DocumentFile>()
|
||||||
|
every { documentFile.exists() } returnsMany listOf(true, true)
|
||||||
|
every { documentFile.delete() } returns false
|
||||||
|
|
||||||
|
assertFalse(removeSourceDocument(documentFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -23,4 +23,9 @@ data class AutomaticUploadsConfiguration(
|
|||||||
val videoUploadsConfiguration: FolderBackUpConfiguration?
|
val videoUploadsConfiguration: FolderBackUpConfiguration?
|
||||||
) {
|
) {
|
||||||
fun areAutomaticUploadsDisabled() = pictureUploadsConfiguration == null && videoUploadsConfiguration == null
|
fun areAutomaticUploadsDisabled() = pictureUploadsConfiguration == null && videoUploadsConfiguration == null
|
||||||
|
|
||||||
|
val sourcePaths: List<String>
|
||||||
|
get() = listOfNotNull(pictureUploadsConfiguration, videoUploadsConfiguration)
|
||||||
|
.flatMap { it.sourcePaths }
|
||||||
|
.distinct()
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -35,6 +35,13 @@ data class FolderBackUpConfiguration(
|
|||||||
val isVideoUploads get() = name == videoUploadsName
|
val isVideoUploads get() = name == videoUploadsName
|
||||||
val sourcePaths get() = parseSourcePaths(sourcePath)
|
val sourcePaths get() = parseSourcePaths(sourcePath)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picture uploads are an ingest operation: the source is removed only after
|
||||||
|
* the upload worker has completed successfully. Video uploads keep their
|
||||||
|
* explicitly configured behavior.
|
||||||
|
*/
|
||||||
|
val effectiveBehavior get() = if (isPictureUploads) UploadBehavior.MOVE else behavior
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val pictureUploadsName = "Picture uploads"
|
const val pictureUploadsName = "Picture uploads"
|
||||||
const val videoUploadsName = "Video uploads"
|
const val videoUploadsName = "Video uploads"
|
||||||
|
|||||||
+35
@@ -59,4 +59,39 @@ class FolderBackUpConfigurationTest {
|
|||||||
|
|
||||||
assertEquals("$firstSourcePath\n$secondSourcePath", encodedSourcePaths)
|
assertEquals("$firstSourcePath\n$secondSourcePath", encodedSourcePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `picture uploads always remove source after successful upload`() {
|
||||||
|
val configuration = folderBackUpConfiguration(
|
||||||
|
name = FolderBackUpConfiguration.pictureUploadsName,
|
||||||
|
behavior = UploadBehavior.COPY,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(UploadBehavior.MOVE, configuration.effectiveBehavior)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `video uploads preserve configured behavior`() {
|
||||||
|
val configuration = folderBackUpConfiguration(
|
||||||
|
name = FolderBackUpConfiguration.videoUploadsName,
|
||||||
|
behavior = UploadBehavior.COPY,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(UploadBehavior.COPY, configuration.effectiveBehavior)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun folderBackUpConfiguration(
|
||||||
|
name: String,
|
||||||
|
behavior: UploadBehavior,
|
||||||
|
) = FolderBackUpConfiguration(
|
||||||
|
accountName = "account",
|
||||||
|
behavior = behavior,
|
||||||
|
sourcePath = "content://source",
|
||||||
|
uploadPath = "/CameraUpload",
|
||||||
|
wifiOnly = false,
|
||||||
|
chargingOnly = false,
|
||||||
|
lastSyncTimestamp = 0,
|
||||||
|
name = name,
|
||||||
|
spaceId = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# QSfera
|
||||||
|
|
||||||
|
This repository contains the QSfera clients and server as one workspace.
|
||||||
|
|
||||||
|
| Component | Directory | Primary local check |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Android app | [`Android`](Android/) | `cd Android && ./gradlew :qsferaApp:assembleOriginalDebug` |
|
||||||
|
| Desktop client | [`Desktop`](Desktop/) | See [`Desktop/README.md`](Desktop/README.md) |
|
||||||
|
| Server | [`Server`](Server/) | `cd Server && make test` |
|
||||||
|
|
||||||
|
## Android automatic photo uploads
|
||||||
|
|
||||||
|
The Android app can monitor user-selected Storage Access Framework folders,
|
||||||
|
including the camera folder. New media is queued through WorkManager, uploaded,
|
||||||
|
and removed from the source folder only after a successful transfer. The
|
||||||
|
periodic worker remains as a fallback for document providers that do not emit a
|
||||||
|
content-change notification.
|
||||||
|
|
||||||
|
## Raspberry Pi server
|
||||||
|
|
||||||
|
The production-oriented ARM64 Compose profile, health check, SSD layout and
|
||||||
|
backup/restore scripts are documented in
|
||||||
|
[`Server/deployments/raspberry-pi`](Server/deployments/raspberry-pi/README.md).
|
||||||
|
|
||||||
|
## CI and dependency updates
|
||||||
|
|
||||||
|
Root GitHub workflows run Android checks and server security/deployment checks
|
||||||
|
from their actual monorepo paths. Root Dependabot configuration covers Gradle,
|
||||||
|
Go modules, the server IdP npm project, Docker and GitHub Actions.
|
||||||
+1
-11
@@ -649,17 +649,7 @@ def buildWebCache(ctx):
|
|||||||
|
|
||||||
def testQsferaAndUploadResults(ctx):
|
def testQsferaAndUploadResults(ctx):
|
||||||
unit_pipeline = testQsfera(ctx)
|
unit_pipeline = testQsfera(ctx)
|
||||||
|
return scanQsfera(ctx) + unit_pipeline
|
||||||
######################################################################
|
|
||||||
# The triggers have been disabled for now, since the govulncheck can #
|
|
||||||
# not silence single, acceptable vulnerabilities. #
|
|
||||||
# See https://github.com/owncloud/ocis/issues/9527 for more details. #
|
|
||||||
# FIXME: RE-ENABLE THIS ASAP!!! #
|
|
||||||
######################################################################
|
|
||||||
|
|
||||||
#security_scan = scanQsfera(ctx)
|
|
||||||
#return [security_scan] + unit_pipeline + [scan_result_upload]
|
|
||||||
return unit_pipeline
|
|
||||||
|
|
||||||
def testPipelines(ctx):
|
def testPipelines(ctx):
|
||||||
pipelines = []
|
pipelines = []
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ LABEL maintainer="QSfera" \
|
|||||||
org.opencontainers.image.documentation="" \
|
org.opencontainers.image.documentation="" \
|
||||||
org.opencontainers.image.source=""
|
org.opencontainers.image.source=""
|
||||||
|
|
||||||
|
RUN addgroup -g 1000 -S qsfera-group && \
|
||||||
|
adduser -S --ingroup qsfera-group --uid 1000 qsfera-user --home /var/lib/qsfera && \
|
||||||
|
mkdir -p /var/lib/qsfera/web/assets/apps /etc/qsfera && \
|
||||||
|
chown -R qsfera-user:qsfera-group /var/lib/qsfera /etc/qsfera && \
|
||||||
|
chmod -R 751 /var/lib/qsfera /etc/qsfera
|
||||||
|
|
||||||
|
VOLUME ["/var/lib/qsfera", "/etc/qsfera"]
|
||||||
|
WORKDIR /var/lib/qsfera
|
||||||
|
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
EXPOSE 9200/tcp
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/qsfera"]
|
ENTRYPOINT ["/usr/bin/qsfera"]
|
||||||
CMD ["server"]
|
CMD ["server"]
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -210,11 +210,11 @@ protobuf:
|
|||||||
|
|
||||||
.PHONY: golangci-lint
|
.PHONY: golangci-lint
|
||||||
golangci-lint: $(GOLANGCI_LINT)
|
golangci-lint: $(GOLANGCI_LINT)
|
||||||
$(GOLANGCI_LINT) run --modules-download-mode vendor --timeout 15m0s --issues-exit-code 0 --out-format checkstyle > checkstyle.xml
|
$(GOLANGCI_LINT) run --modules-download-mode vendor --timeout 15m0s --issues-exit-code 1 --out-format checkstyle > checkstyle.xml
|
||||||
|
|
||||||
.PHONY: ci-golangci-lint
|
.PHONY: ci-golangci-lint
|
||||||
ci-golangci-lint:
|
ci-golangci-lint:
|
||||||
$(GOLANGCI_LINT) run --modules-download-mode vendor --timeout 15m0s --issues-exit-code 0 --out-format checkstyle > checkstyle.xml
|
$(GOLANGCI_LINT) run --modules-download-mode vendor --timeout 15m0s --issues-exit-code 1 --out-format checkstyle > checkstyle.xml
|
||||||
|
|
||||||
.PHONY: golangci-lint-fix
|
.PHONY: golangci-lint-fix
|
||||||
golangci-lint-fix: $(GOLANGCI_LINT)
|
golangci-lint-fix: $(GOLANGCI_LINT)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Use an immutable release tag or digest that contains linux/arm64.
|
||||||
|
QSFERA_IMAGE=
|
||||||
|
|
||||||
|
# Public URL used by the Android, desktop and web clients.
|
||||||
|
QSFERA_URL=
|
||||||
|
|
||||||
|
# Bind to localhost when a reverse proxy runs on the Pi.
|
||||||
|
QSFERA_BIND_IP=127.0.0.1
|
||||||
|
QSFERA_PROXY_TLS=true
|
||||||
|
QSFERA_INSECURE=false
|
||||||
|
QSFERA_ENABLE_BASIC_AUTH=false
|
||||||
|
QSFERA_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Do not reuse the example or demo password.
|
||||||
|
QSFERA_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Absolute directories on persistent storage. An external SSD is recommended.
|
||||||
|
QSFERA_CONFIG_DIR=
|
||||||
|
QSFERA_DATA_DIR=
|
||||||
|
|
||||||
|
# Set after measuring RAM used by the OS and other services, for example 2g.
|
||||||
|
QSFERA_MEMORY_LIMIT=
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Raspberry Pi deployment
|
||||||
|
|
||||||
|
This profile runs the single-container QSfera server on a 64-bit Raspberry Pi
|
||||||
|
OS and persists configuration and user data outside the container. It expects a
|
||||||
|
separate reverse proxy for a browser-trusted TLS certificate; by default port
|
||||||
|
9200 is therefore published only on `127.0.0.1`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- `uname -m` must report `aarch64` or `arm64`.
|
||||||
|
- Docker Engine with the Compose plugin must be installed.
|
||||||
|
- The selected QSfera image tag or digest must contain `linux/arm64`.
|
||||||
|
- The configuration and data directories must be on persistent storage and
|
||||||
|
writable by UID/GID `1000:1000`, which is the non-root user in the image.
|
||||||
|
|
||||||
|
For an external disk mounted at `/mnt/qsfera`, create the directories with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o 1000 -g 1000 -m 0750 /mnt/qsfera/config /mnt/qsfera/data
|
||||||
|
findmnt /mnt/qsfera
|
||||||
|
```
|
||||||
|
|
||||||
|
`findmnt` must show the expected external filesystem before the service starts.
|
||||||
|
This prevents an unavailable disk from silently placing data on the Pi's root
|
||||||
|
filesystem.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd Server/deployments/raspberry-pi
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill every empty value in `.env`. The Compose file deliberately has no default
|
||||||
|
for the image, public URL, admin password, persistent paths or memory limit.
|
||||||
|
`docker compose config` fails when any of these values is empty.
|
||||||
|
|
||||||
|
Choose `QSFERA_MEMORY_LIMIT` from measurements on the target Pi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QSFERA_MEMORY_LIMIT = total RAM - OS reserve - other services reserve
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `free -h` for total/current host memory and `docker stats` for the other
|
||||||
|
containers. Leave enough headroom for the kernel, filesystem cache and reverse
|
||||||
|
proxy. QSfera's Go runtime then derives its own default `GOMEMLIMIT` from the
|
||||||
|
container limit with a 0.9 ratio.
|
||||||
|
|
||||||
|
Keep `QSFERA_BIND_IP=127.0.0.1` when the reverse proxy runs on the same Pi. Set
|
||||||
|
it to a reachable interface only when access to port 9200 is protected by an
|
||||||
|
equivalent network and TLS design.
|
||||||
|
|
||||||
|
## Start and verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh ./smoke-test.sh
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke test validates the Compose file, starts the service and waits for the
|
||||||
|
container health check. Its default 180-second startup budget can be changed
|
||||||
|
with `SMOKE_TIMEOUT_SECONDS`.
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
The backup script stops QSfera, archives both persistent directories, writes a
|
||||||
|
SHA-256 checksum and starts the service again:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh ./backup.sh /mnt/backups/qsfera
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the resulting `.tar.gz` and `.sha256` files to storage that is independent
|
||||||
|
of the Pi and its data disk.
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
|
||||||
|
Restore replaces the current configuration and data. Check the selected archive
|
||||||
|
and its checksum first, then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RESTORE_CONFIRM=restore sh ./restore.sh /mnt/backups/qsfera/qsfera-TIMESTAMP.tar.gz
|
||||||
|
sh ./smoke-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The restore script verifies the checksum when the matching `.sha256` file is
|
||||||
|
present and stops the service during replacement. It starts QSfera only after a
|
||||||
|
successful extraction; after an error the service remains stopped so a partial
|
||||||
|
data set is never served automatically.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Raspberry Pi external storage documentation](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#external-storage)
|
||||||
|
- [Docker Compose service configuration](https://docs.docker.com/reference/compose-file/services/)
|
||||||
|
- [Go garbage collector guide](https://go.dev/doc/gc-guide)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
backup_root=${1:-./backups}
|
||||||
|
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
archive="$backup_root/qsfera-$timestamp.tar.gz"
|
||||||
|
temporary="$archive.incomplete"
|
||||||
|
archive_directory=$(dirname "$archive")
|
||||||
|
archive_name=$(basename "$archive")
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$backup_root"
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
docker compose "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$temporary"
|
||||||
|
compose start qsfera >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
compose config --quiet
|
||||||
|
compose stop qsfera
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 1' HUP INT TERM
|
||||||
|
|
||||||
|
compose run --rm --no-deps -T --entrypoint /bin/sh qsfera -ec \
|
||||||
|
'tar -C / -czf - etc/qsfera var/lib/qsfera' >"$temporary"
|
||||||
|
mv "$temporary" "$archive"
|
||||||
|
(cd "$archive_directory" && sha256sum "$archive_name" >"$archive_name.sha256")
|
||||||
|
|
||||||
|
compose start qsfera >/dev/null
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
printf 'Backup: %s\nChecksum: %s\n' "$archive" "$archive.sha256"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: qsfera-pi
|
||||||
|
|
||||||
|
services:
|
||||||
|
qsfera:
|
||||||
|
image: ${QSFERA_IMAGE:?Set QSFERA_IMAGE to a pinned ARM64 image tag}
|
||||||
|
platform: linux/arm64
|
||||||
|
init: true
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
command:
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
if [ ! -f /etc/qsfera/qsfera.yaml ]; then
|
||||||
|
qsfera init
|
||||||
|
fi
|
||||||
|
exec qsfera server
|
||||||
|
environment:
|
||||||
|
OC_BASE_DATA_PATH: /var/lib/qsfera
|
||||||
|
OC_CONFIG_DIR: /etc/qsfera
|
||||||
|
OC_URL: ${QSFERA_URL:?Set QSFERA_URL to the public server URL}
|
||||||
|
OC_LOG_LEVEL: ${QSFERA_LOG_LEVEL:-info}
|
||||||
|
OC_LOG_COLOR: "false"
|
||||||
|
OC_LOG_PRETTY: "false"
|
||||||
|
OC_INSECURE: "${QSFERA_INSECURE:-false}"
|
||||||
|
PROXY_TLS: "${QSFERA_PROXY_TLS:-true}"
|
||||||
|
PROXY_ENABLE_BASIC_AUTH: "${QSFERA_ENABLE_BASIC_AUTH:-false}"
|
||||||
|
IDM_ADMIN_PASSWORD: ${QSFERA_ADMIN_PASSWORD:?Set a strong QSFERA_ADMIN_PASSWORD}
|
||||||
|
IDM_CREATE_DEMO_USERS: "false"
|
||||||
|
ports:
|
||||||
|
- "${QSFERA_BIND_IP:-127.0.0.1}:9200:9200"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${QSFERA_CONFIG_DIR:?Set QSFERA_CONFIG_DIR to a persistent directory}
|
||||||
|
target: /etc/qsfera
|
||||||
|
- type: bind
|
||||||
|
source: ${QSFERA_DATA_DIR:?Set QSFERA_DATA_DIR to a persistent directory}
|
||||||
|
target: /var/lib/qsfera
|
||||||
|
mem_limit: "${QSFERA_MEMORY_LIMIT:?Set QSFERA_MEMORY_LIMIT after measuring available RAM}"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- 'scheme=https; [ "$${PROXY_TLS}" = "false" ] && scheme=http; curl --fail --silent --show-error --insecure "$${scheme}://127.0.0.1:9200/status.php" >/dev/null'
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
archive=${1:-}
|
||||||
|
if [ -z "$archive" ] || [ ! -f "$archive" ]; then
|
||||||
|
echo "Usage: RESTORE_CONFIRM=restore $0 /path/to/qsfera-TIMESTAMP.tar.gz" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${RESTORE_CONFIRM:-}" != "restore" ]; then
|
||||||
|
echo "Restore replaces the current QSfera configuration and data." >&2
|
||||||
|
echo "Re-run with RESTORE_CONFIRM=restore after checking the archive path." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$archive.sha256" ]; then
|
||||||
|
archive_directory=$(dirname "$archive")
|
||||||
|
archive_name=$(basename "$archive")
|
||||||
|
(cd "$archive_directory" && sha256sum --check "$archive_name.sha256")
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
docker compose "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_completed=false
|
||||||
|
|
||||||
|
finish_restore() {
|
||||||
|
if [ "$restore_completed" = "true" ]; then
|
||||||
|
compose start qsfera >/dev/null
|
||||||
|
else
|
||||||
|
echo "Restore failed; QSfera remains stopped to protect the data set." >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compose config --quiet
|
||||||
|
compose stop qsfera
|
||||||
|
trap finish_restore EXIT
|
||||||
|
trap 'exit 1' HUP INT TERM
|
||||||
|
|
||||||
|
compose run --rm --no-deps -T --entrypoint /bin/sh qsfera -ec \
|
||||||
|
'find /etc/qsfera -mindepth 1 -maxdepth 1 -exec rm -rf {} +; find /var/lib/qsfera -mindepth 1 -maxdepth 1 -exec rm -rf {} +; tar -C / -xzf -' \
|
||||||
|
<"$archive"
|
||||||
|
|
||||||
|
restore_completed=true
|
||||||
|
compose start qsfera >/dev/null
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
echo "Restore completed. Run sh ./smoke-test.sh to verify startup and health."
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
aarch64|arm64) ;;
|
||||||
|
*)
|
||||||
|
echo "This profile requires a 64-bit ARM OS; uname -m returned $(uname -m)." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
timeout_seconds=${SMOKE_TIMEOUT_SECONDS:-180}
|
||||||
|
started_at=$(date +%s)
|
||||||
|
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose up -d
|
||||||
|
container_id=$(docker compose ps -q qsfera)
|
||||||
|
|
||||||
|
if [ -z "$container_id" ]; then
|
||||||
|
echo "QSfera container was not created." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
state=$(docker inspect --format '{{.State.Status}}' "$container_id")
|
||||||
|
if [ "$state" != "running" ]; then
|
||||||
|
docker compose logs --tail=100 qsfera >&2
|
||||||
|
echo "QSfera container state is $state." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_id")
|
||||||
|
case "$health" in
|
||||||
|
healthy)
|
||||||
|
echo "QSfera container is healthy."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
unhealthy)
|
||||||
|
docker compose logs --tail=100 qsfera >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
now=$(date +%s)
|
||||||
|
if [ $((now - started_at)) -ge "$timeout_seconds" ]; then
|
||||||
|
docker compose logs --tail=100 qsfera >&2
|
||||||
|
echo "QSfera did not become healthy within ${timeout_seconds}s." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
@@ -15,7 +15,7 @@ To configure which registry to use, you have to set the environment variable `MI
|
|||||||
|
|
||||||
## Memory limits
|
## Memory limits
|
||||||
|
|
||||||
КуСфера will automatically set the go native `GOMEMLIMIT` to `0.9`. To disable the limit set `AUTOMEMEMLIMIT=off`. For more information take a look at the official [Guide to the Go Garbage Collector](https://go.dev/doc/gc-guide).
|
КуСфера will automatically set the go native `GOMEMLIMIT` to `0.9`. To disable the limit set `AUTOMEMLIMIT=off`. For more information take a look at the official [Guide to the Go Garbage Collector](https://go.dev/doc/gc-guide).
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user