Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ Releases marked with 🧪 (or previously with the "beta" suffix) were released o

### Next release

* 🔧 Import: support importing movies with only IMDB IDs.
* 🔧 Import: support new "imdb-movie" list item type, maps to added movie using "externalId" as IMDB ID on import, otherwise skips.
🔧 Import: display a simple summary of imported and skipped items.
* 🔧 Import: restore original data on failure.

## Version 2026.2

* 🌟 Lists: movies can be added to lists, currently only from the details view.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.battlelancer.seriesguide.R
import com.battlelancer.seriesguide.SgApp
import com.battlelancer.seriesguide.databinding.FragmentAutoBackupBinding
import com.battlelancer.seriesguide.dataliberation.DataLiberationFragment.LiberationResultEvent
import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export
Expand Down Expand Up @@ -68,10 +67,10 @@ class AutoBackupFragment : Fragment() {

binding.buttonAutoBackupNow.setOnClickListener {
if (TaskManager.tryBackupTask(requireContext())) {
setProgressLock(true)
viewModel.setInProgress(true)
}
}
binding.buttonAutoBackupImport.setOnClickListener { runAutoBackupImport() }
binding.buttonAutoBackupImport.setOnClickListener { viewModel.runImportTask() }

binding.checkBoxAutoBackupCreateCopy.isChecked =
BackupSettings.isCreateCopyOfAutoBackup(requireContext())
Expand Down Expand Up @@ -115,12 +114,13 @@ class AutoBackupFragment : Fragment() {

binding.groupState.visibility = View.GONE
viewModel.updateCopiesFileNames()
setProgressLock(false) // Also disables import button if backup availability unknown.

// restore UI state
if (viewModel.isImportTaskNotCompleted) {
setProgressLock(true)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.isInProgress.collect {
setProgressLock(it)
}
}

viewModel.availableBackupLiveData
.observe(viewLifecycleOwner, { availableBackupTimeString: String? ->
val lastBackupTimeString =
Expand Down Expand Up @@ -157,6 +157,16 @@ class AutoBackupFragment : Fragment() {
}
}
})

viewLifecycleOwner.lifecycleScope.launch {
viewModel.importSummaryState.collect { state ->
state.applyTo(
viewModel,
binding.textViewAutoBackupImportSummary,
binding.scrollViewAutoBackup
)
}
}
}

override fun onStart() {
Expand All @@ -183,6 +193,7 @@ class AutoBackupFragment : Fragment() {
binding = null
}

// This is currently only posted by the export task
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: LiberationResultEvent) {
event.handle(view)
Expand All @@ -193,14 +204,7 @@ class AutoBackupFragment : Fragment() {
viewModel.updateAvailableBackupData()
// Note: backup may remove a copy file URI
viewModel.updateCopiesFileNames()
setProgressLock(false)
}

private fun runAutoBackupImport() {
setProgressLock(true)

val importTask = JsonImportTask(requireContext())
viewModel.importTask = SgApp.coroutineScope.launch { importTask.run() }
viewModel.setInProgress(false)
}

private val createShowExportFileResult =
Expand Down Expand Up @@ -235,6 +239,10 @@ class AutoBackupFragment : Fragment() {
binding.buttonAutoBackupImport.isEnabled = isBackupAvailableForImport
}

/**
* Also disables import button if backup availability unknown, so make sure to also call after
* view setup.
*/
private fun setProgressLock(isLocked: Boolean) {
val binding = binding ?: return
binding.buttonAutoBackupNow.isEnabled = !isLocked
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@
package com.battlelancer.seriesguide.dataliberation

import android.app.Application
import android.content.Context
import android.text.format.DateUtils
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.battlelancer.seriesguide.R
import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.getFileNameFromUriOrLastPathSegment
import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch

/**
* View model that checks for available backup files.
*/
class AutoBackupViewModel(application: Application) : AndroidViewModel(application) {
class AutoBackupViewModel(application: Application) : BaseDataLiberationViewModel(application) {

data class CopiesFiles(
val fileNameShows: String?,
Expand All @@ -31,17 +30,6 @@ class AutoBackupViewModel(application: Application) : AndroidViewModel(applicati

val copiesFiles = MutableStateFlow(CopiesFiles("", "", "", "", visible = false))

/**
* Try to keep the import task around on config changes
* so it does not have to be finished.
*/
var importTask: Job? = null
val isImportTaskNotCompleted: Boolean
get() {
val importTask = importTask
return importTask != null && !importTask.isCompleted
}

/** Time string of the available backup, or null if no backup is available. */
val availableBackupLiveData = MutableLiveData<String?>()

Expand Down Expand Up @@ -115,11 +103,9 @@ class AutoBackupViewModel(application: Application) : AndroidViewModel(applicati
}
}

override fun onCleared() {
if (isImportTaskNotCompleted) {
importTask?.cancel(null)
}
importTask = null
fun runImportTask() {
val context: Context = getApplication()
runImportTask { JsonImportTask(context) }
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann <uwe@uwetrottmann.com>

package com.battlelancer.seriesguide.dataliberation

import android.app.Application
import android.widget.TextView
import androidx.core.widget.NestedScrollView
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.battlelancer.seriesguide.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

/**
* Supports running an import task, provides state flows for progress and import summary.
*/
open class BaseDataLiberationViewModel(application: Application) : AndroidViewModel(application) {

data class ImportSummaryUiState(
val message: String?,
val isError: Boolean,
val handled: Boolean = false
) {
// This introduces Android UI classes into imports, but is fine as the model is currently
// not used in unit tests.
fun applyTo(
model: BaseDataLiberationViewModel,
textViewSummary: TextView,
scrollView: NestedScrollView
) {
textViewSummary.apply {
setTextAppearance(
if (isError) {
R.style.TextAppearance_SeriesGuide_Body2_Error
} else {
R.style.TextAppearance_SeriesGuide_Body2
}
)
text = message
}
if (!handled) {
model.importSummaryState.update { it.copy(handled = true) }

// Scroll the top of the summary into view
scrollView.post {
scrollView.scrollTo(0, textViewSummary.top)
}
}
}
}

val isInProgress = MutableStateFlow(false)
val importSummaryState = MutableStateFlow(
ImportSummaryUiState(
message = null,
isError = false,
handled = true
)
)

fun setInProgress(value: Boolean) {
isInProgress.value = value
}

fun runImportTask(
jsonImportTask: () -> JsonImportTask
) {
setInProgress(true)

viewModelScope.launch(Dispatchers.Default) {
val result = jsonImportTask().run()

setInProgress(false)
importSummaryState.value = ImportSummaryUiState(
result.message,
result.isError
)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

package com.battlelancer.seriesguide.dataliberation

import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
Expand All @@ -14,13 +13,10 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.battlelancer.seriesguide.R
import com.battlelancer.seriesguide.SgApp
import com.battlelancer.seriesguide.databinding.FragmentDataLiberationBinding
import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.CreateExportFileContract
import com.battlelancer.seriesguide.dataliberation.DataLiberationTools.SelectImportFileContract
import com.battlelancer.seriesguide.dataliberation.JsonExportTask.Export
import com.battlelancer.seriesguide.dataliberation.JsonExportTask.OnTaskProgressListener
import com.battlelancer.seriesguide.util.TextTools
import com.battlelancer.seriesguide.util.ThemeUtils
import com.battlelancer.seriesguide.util.ViewTools.openUriOnClick
import com.battlelancer.seriesguide.util.tryLaunch
Expand All @@ -34,7 +30,7 @@ import org.greenrobot.eventbus.ThreadMode
* One button export or import of shows, lists and movies to or from a JSON file.
* Uses Storage Access Framework so no permissions are required.
*/
class DataLiberationFragment : Fragment(), OnTaskProgressListener {
class DataLiberationFragment : Fragment() {

private var binding: FragmentDataLiberationBinding? = null
private val model: DataLiberationViewModel by viewModels()
Expand Down Expand Up @@ -109,9 +105,26 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener {
model.updateImportFileNames()
}

// restore UI state
if (model.isDataLibTaskNotCompleted) {
setProgressLock(true)
viewLifecycleOwner.lifecycleScope.launch {
model.importSummaryState.collect { state ->
state.applyTo(
model,
binding.textViewDataLibImportSummary,
binding.scrollViewDataLiberation
)
}
}

viewLifecycleOwner.lifecycleScope.launch {
model.isInProgress.collect {
setProgressLock(it)
}
}

viewLifecycleOwner.lifecycleScope.launch {
model.exportProgressState.collect {
updateProgressBar(it.total, it.completed)
}
}
}

Expand All @@ -137,13 +150,14 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener {
binding = null
}

override fun onProgressUpdate(total: Int, completed: Int) {
private fun updateProgressBar(total: Int, completed: Int) {
val binding = binding ?: return
binding.progressBarDataLib.isIndeterminate = total == completed
binding.progressBarDataLib.max = total
binding.progressBarDataLib.progress = completed
}

// This is currently only posted by the export task
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: LiberationResultEvent) {
event.handle(view)
Expand All @@ -153,7 +167,7 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener {
}
// Note: export may remove an export file URI which may be used as a default import file
model.updateImportFileNames()
setProgressLock(false)
model.setInProgress(false)
}

private fun setProgressLock(isLocked: Boolean) {
Expand All @@ -178,14 +192,11 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener {

private fun doDataImport() {
val binding = binding ?: return
setProgressLock(true)

val dataLibTask = JsonImportTask(
requireContext(),
binding.checkBoxDataLibShows.isChecked, binding.checkBoxDataLibLists.isChecked,
binding.checkBoxDataLibMovies.isChecked
model.runImportTask(
importShows = binding.checkBoxDataLibShows.isChecked,
importLists = binding.checkBoxDataLibLists.isChecked,
importMovies = binding.checkBoxDataLibMovies.isChecked
)
model.dataLibJob = SgApp.coroutineScope.launch { dataLibTask.run() }
}

private fun doDataExport(export: Export, uri: Uri?) {
Expand All @@ -196,14 +207,8 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener {
model.updateImportFileNames()

val binding = binding ?: return
setProgressLock(true)

val exportTask = JsonExportTask(
requireContext(),
this@DataLiberationFragment,
binding.checkBoxDataLibFullDump.isChecked
)
model.dataLibJob = exportTask.launch(export)
model.runExportTask(export, binding.checkBoxDataLibFullDump.isChecked)
}

private val createShowExportFileResult =
Expand Down Expand Up @@ -265,12 +270,10 @@ class DataLiberationFragment : Fragment(), OnTaskProgressListener {
}

constructor(
context: Context,
message: String?,
errorCause: String?,
showIndefinite: Boolean
) {
this.message = TextTools.dotSeparate(context, message, errorCause)
this.message = message
this.showIndefinite = showIndefinite
}

Expand Down
Loading