Skip to content

Commit

Permalink
feat: add zip export for exercises
Browse files Browse the repository at this point in the history
  • Loading branch information
JanMalch committed Apr 13, 2024
1 parent 79f8645 commit f028892
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 4 deletions.
11 changes: 10 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
Expand Down Expand Up @@ -65,5 +64,15 @@
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="io.github.janmalch.woroboro.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,8 @@ interface BusinessModule {
@Singleton
fun bindsMediaOptimizer(impl: OnDeviceMediaOptimizer): MediaOptimizer

@Binds
@Singleton
fun bindsImportExportManager(impl: ImportExportManagerImpl): ImportExportManager

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ interface ExerciseRepository {
* Also filters the list to only include favorites, if [onlyFavorites] is set to `true`.
* Otherwise returns both favorites and non-favorites.
*/
fun findAll(tags: List<String>, onlyFavorites: Boolean, textQuery: String): Flow<List<Exercise>>
fun findAll(
tags: List<String> = emptyList(),
onlyFavorites: Boolean = false,
textQuery: String = ""
): Flow<List<Exercise>>
suspend fun delete(id: UUID)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.github.janmalch.woroboro.data.model.RoutineEntity
import io.github.janmalch.woroboro.data.model.RoutineStepEntity
import io.github.janmalch.woroboro.data.model.TagEntity
import io.github.janmalch.woroboro.models.Exercise
import io.github.janmalch.woroboro.models.ExerciseExecution
import io.github.janmalch.woroboro.models.FullRoutine
import io.github.janmalch.woroboro.models.Media
import io.github.janmalch.woroboro.models.Reminder
Expand Down Expand Up @@ -102,3 +103,31 @@ fun Reminder.asEntities(): Pair<ReminderEntity, List<String>> {
routinesOrder = filter.routinesOrder,
) to filter.selectedTags.map { it.label }
}

fun Exercise.asText(includeMedia: Boolean): String = """Exercise: $name
${execution.asText()}
${
media.joinToString(
prefix = "[",
separator = ",",
postfix = "]",
transform = { it.id.toString() }).takeIf { media.isNotEmpty() && includeMedia }
}
$description"""

private fun ExerciseExecution.asText(): String = listOfNotNull(
sets.toString() + "x",
reps?.let { "Reps = $it" },
hold?.let { "Hold = $it" },
pause?.let { "Pause = $it" },
).joinToString()

/*
fun FullRoutine.asText(): String = """Routine: $name
${steps.joinToString(separator = "\n", transform = { it.asText() })}"""
private fun RoutineStep.asText(): String = when (this) {
is RoutineStep.ExerciseStep -> "Exercise: ${exercise.id} (${customExecution?.asText() ?: ""})" // FIXME: how to ref?
is RoutineStep.PauseStep -> "Pause: $duration"
}
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package io.github.janmalch.woroboro.business

import android.content.Context
import android.util.Log
import androidx.core.net.toFile
import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.janmalch.woroboro.models.Exercise
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import java.time.LocalDateTime
import javax.inject.Inject

interface ImportExportManager {

suspend fun export(): File

suspend fun clean()
}


class ImportExportManagerImpl @Inject constructor(
private val exerciseRepository: ExerciseRepository,
private val routineRepository: RoutineRepository,
@ApplicationContext private val context: Context,
) : ImportExportManager {

private val exportsDir = File(context.cacheDir, "exports").also(File::mkdirs)

override suspend fun export(): File {
val exercises = exerciseRepository.findAll().first()
val routines = routineRepository.findAll().first()

return withContext(Dispatchers.IO) {
val exportFile = File(
exportsDir,
"woroboro-${
LocalDateTime.now().toString().replace('T', '_').replace('.', '-')
.replace(':', '-')
}.zip"
)
exportFile.createNewFile()
writeZip(exportFile,
exercises.map<Exercise, ZipContent> {
ZipContent.TextFile(
name = it.name + ".woroboro.txt",
content = it.asText(includeMedia = true)
)
} + exercises.flatMap {
it.media.map { media ->
ZipContent.MediaFile(
file = media.source.toUri().toFile()
)
}
}
)
exportFile
}
}

override suspend fun clean() {
withContext(Dispatchers.IO) {
exportsDir.listFiles()?.forEach { file ->
runCatching { file.delete() }
.onFailure { tr ->
Log.e(
"ImportExportManagerImpl",
"Error while deleting export file.",
tr
)
}.onSuccess { deleted ->
if (deleted) {
Log.d(
"ImportExportManagerImpl",
"Export file '${file.name}' has been deleted successfully."
)
} else {
Log.w(
"ImportExportManagerImpl",
"Export file '${file.name}' has not been deleted."
)

}
}
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.github.janmalch.woroboro.business

import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

sealed interface ZipContent {
data class TextFile(val name: String, val content: String) : ZipContent
data class MediaFile(val file: File) : ZipContent
}

fun writeZip(
dest: File,
contents: List<ZipContent>,
) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(dest))).use { out ->
for (content in contents) {
when (content) {
is ZipContent.MediaFile -> {
FileInputStream(content.file).use { fi ->
BufferedInputStream(fi).use { buffi ->
val entry = ZipEntry(content.file.name)
out.putNextEntry(entry)
buffi.copyTo(out, 1024)
out.closeEntry()
}
}
}

is ZipContent.TextFile -> {
val entry = ZipEntry(content.name)
out.putNextEntry(entry)
out.write(content.content.toByteArray())
out.closeEntry()
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ enum class Outcome {
Failure
}

sealed interface DataOutcome<T> {
data class Success<T>(val data: T) : DataOutcome<T>
class Failure<T> : DataOutcome<T>
}

@Composable
fun <T> CollectAsEvents(
flow: Flow<T>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import io.github.janmalch.woroboro.R
fun MoreScreen(
onViewLicenses: () -> Unit,
onClearLastRuns: () -> Unit,
onExport: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
Expand Down Expand Up @@ -78,7 +79,7 @@ fun MoreScreen(
supportingContent = {
Text(text = stringResource(R.string.zip_export_explanation))
},
modifier = Modifier.clickable(onClick = { /* TODO */ })
modifier = Modifier.clickable(onClick = onExport)
)
HorizontalDivider()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.navigation.compose.composable
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import io.github.janmalch.woroboro.R
import io.github.janmalch.woroboro.ui.CollectAsEvents
import io.github.janmalch.woroboro.ui.DataOutcome
import io.github.janmalch.woroboro.ui.Outcome


Expand All @@ -22,6 +23,7 @@ fun NavGraphBuilder.moreScreen(
) {
val context = LocalContext.current
val viewModel = hiltViewModel<MoreScreenViewModel>()
val share = rememberShareFunction(onResult = { viewModel.cleanExports() })

CollectAsEvents(viewModel.onClearLastRunsFinished) {
val message = when (it) {
Expand All @@ -31,6 +33,13 @@ fun NavGraphBuilder.moreScreen(
onShowSnackbar(message)
}

CollectAsEvents(viewModel.onExportFinished) {
when (it) {
is DataOutcome.Success -> share(it.data)
is DataOutcome.Failure -> onShowSnackbar(context.getString(R.string.unknown_error_message))
}
}

MoreScreen(
onViewLicenses = {
context.startActivity(
Expand All @@ -40,7 +49,8 @@ fun NavGraphBuilder.moreScreen(
)
)
},
onClearLastRuns = { viewModel.clearLastRuns() }
onClearLastRuns = { viewModel.clearLastRuns() },
onExport = { viewModel.export() },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.github.janmalch.woroboro.business.ImportExportManager
import io.github.janmalch.woroboro.business.RoutineRepository
import io.github.janmalch.woroboro.ui.DataOutcome
import io.github.janmalch.woroboro.ui.Outcome
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject

@HiltViewModel
class MoreScreenViewModel @Inject constructor(
private val routineRepository: RoutineRepository,
private val importExport: ImportExportManager,
) : ViewModel() {

private val _onClearLastRunsFinished = Channel<Outcome>()
Expand All @@ -27,11 +31,34 @@ class MoreScreenViewModel @Inject constructor(
}
}

private val _onExportFinished = Channel<DataOutcome<File>>()
val onExportFinished = _onExportFinished.receiveAsFlow()

private val onExportFinishedExceptionHandler = CoroutineExceptionHandler { _, exception ->
Log.e("MoreScreenViewModel", "Error while creating export.", exception)
viewModelScope.launch {
_onExportFinished.send(DataOutcome.Failure())
}
}

fun clearLastRuns() {
viewModelScope.launch(clearLastRunsExceptionHandler) {
routineRepository.clearLastRuns()
_onClearLastRunsFinished.send(Outcome.Success)
}
}

fun export() {
viewModelScope.launch(onExportFinishedExceptionHandler) {
val file = importExport.export()
_onExportFinished.send(DataOutcome.Success(file))
}
}

fun cleanExports() {
viewModelScope.launch {
importExport.clean()
}
}

}
Loading

0 comments on commit f028892

Please sign in to comment.