feat: show stacktrace in installer ui (#36)

This commit is contained in:
Ax333l 2023-06-17 13:45:52 +02:00 committed by GitHub
parent 41c521876a
commit d59c57a882
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 236 additions and 172 deletions

View File

@ -89,7 +89,9 @@ dependencies {
implementation("me.zhanghai.android.appiconloader:appiconloader-coil:1.5.0")
// KotlinX
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
val serializationVersion = "1.5.1"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:$serializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// Room

View File

@ -25,9 +25,6 @@ class Session(
private val input: File,
private val onProgress: suspend (Progress) -> Unit = { }
) : Closeable {
class PatchFailedException(val patchName: String, cause: Throwable?) :
Exception("Got exception while executing $patchName", cause)
private val logger = LogcatLogger
private val temporary = File(cacheDir).resolve("manager").also { it.mkdirs() }
private val patcher = Patcher(
@ -48,9 +45,11 @@ class Session(
return@forEach
}
logger.error("$patch failed:")
result.exceptionOrNull()!!.printStackTrace()
result.exceptionOrNull()!!.let {
logger.error(result.exceptionOrNull()!!.stackTraceToString())
throw PatchFailedException(patch, result.exceptionOrNull())
throw it
}
}
}

View File

@ -2,13 +2,11 @@ package app.revanced.manager.patcher.worker
import android.content.Context
import androidx.annotation.StringRes
import androidx.work.Data
import androidx.work.workDataOf
import app.revanced.manager.R
import app.revanced.manager.util.serialize
import kotlinx.collections.immutable.persistentListOf
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
sealed class Progress {
object Unpacking : Progress()
@ -21,117 +19,116 @@ sealed class Progress {
}
@Serializable
enum class StepStatus {
WAITING,
COMPLETED,
FAILURE,
enum class State {
WAITING, COMPLETED, FAILED
}
@Serializable
class Step(val name: String, val status: StepStatus = StepStatus.WAITING)
class SubStep(
val name: String,
val state: State = State.WAITING,
@SerialName("msg")
val message: String? = null
)
@Serializable
class StepGroup(
class Step(
@StringRes val name: Int,
val steps: List<Step>,
val status: StepStatus = StepStatus.WAITING
val substeps: List<SubStep>,
val state: State = State.WAITING
)
class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
val stepGroups = generateGroupsList(context, selectedPatches)
val steps = generateSteps(context, selectedPatches)
private var currentStep: StepKey? = StepKey(0, 0)
companion object {
private const val WORK_DATA_KEY = "progress"
private fun update(key: StepKey, state: State, message: String? = null) {
val isLastSubStep: Boolean
steps[key.step] = steps[key.step].let { step ->
isLastSubStep = key.substep == step.substeps.lastIndex
/**
* A map of [Progress] to the corresponding position in [stepGroups]
*/
private val stepKeyMap = mapOf(
Progress.Unpacking to StepKey(0, 0),
Progress.Merging to StepKey(0, 1),
Progress.PatchingStart to StepKey(1, 0),
Progress.Saving to StepKey(2, 0),
)
fun generateGroupsList(context: Context, selectedPatches: List<String>) = mutableListOf(
StepGroup(
R.string.patcher_step_group_prepare,
persistentListOf(
Step(context.getString(R.string.patcher_step_unpack)),
Step(context.getString(R.string.patcher_step_integrations))
)
),
StepGroup(
R.string.patcher_step_group_patching,
selectedPatches.map { Step(it) }
),
StepGroup(
R.string.patcher_step_group_saving,
persistentListOf(Step(context.getString(R.string.patcher_step_write_patched)))
)
)
fun groupsFromWorkData(workData: Data) = workData.getString(WORK_DATA_KEY)
?.let { Json.decodeFromString<List<StepGroup>>(it) }
}
fun groupsToWorkData() = workDataOf(WORK_DATA_KEY to Json.Default.encodeToString(stepGroups))
private var currentStep: StepKey? = null
private fun <T> MutableList<T>.mutateIndex(index: Int, callback: (T) -> T) = apply {
this[index] = callback(this[index])
}
private fun updateStepStatus(key: StepKey, newStatus: StepStatus) {
var isLastStepOfGroup = false
stepGroups.mutateIndex(key.groupIndex) { group ->
isLastStepOfGroup = key.stepIndex == group.steps.lastIndex
val newGroupStatus = when {
// This group failed if a step in it failed.
newStatus == StepStatus.FAILURE -> StepStatus.FAILURE
// All steps in the group succeeded.
newStatus == StepStatus.COMPLETED && isLastStepOfGroup -> StepStatus.COMPLETED
val newStepState = when {
// This step failed because one of its sub-steps failed.
state == State.FAILED -> State.FAILED
// All sub-steps succeeded.
state == State.COMPLETED && isLastSubStep -> State.COMPLETED
// Keep the old status.
else -> group.status
else -> step.state
}
StepGroup(group.name, group.steps.toMutableList().mutateIndex(key.stepIndex) { step ->
Step(step.name, newStatus)
}, newGroupStatus)
Step(step.name, step.substeps.mapIndexed { index, subStep ->
if (index != key.substep) subStep else SubStep(subStep.name, state, message)
}, newStepState)
}
val isFinalStep = isLastStepOfGroup && key.groupIndex == stepGroups.lastIndex
val isFinal = isLastSubStep && key.step == steps.lastIndex
if (newStatus == StepStatus.COMPLETED) {
if (state == State.COMPLETED) {
// Move the cursor to the next step.
currentStep = when {
isFinalStep -> null // Final step has been completed.
isLastStepOfGroup -> StepKey(key.groupIndex + 1, 0) // Move to the next group.
isFinal -> null // Final step has been completed.
isLastSubStep -> StepKey(key.step + 1, 0) // Move to the next step.
else -> StepKey(
key.groupIndex,
key.stepIndex + 1
) // Move to the next step of this group.
key.step,
key.substep + 1
) // Move to the next sub-step.
}
}
}
private fun setCurrentStepStatus(newStatus: StepStatus) =
currentStep?.let { updateStepStatus(it, newStatus) }
fun replacePatchesList(newList: List<String>) {
steps[stepKeyMap[Progress.PatchingStart]!!.step] = generatePatchesStep(newList)
}
private fun updateCurrent(newState: State, message: String? = null) =
currentStep?.let { update(it, newState, message) }
private data class StepKey(val groupIndex: Int, val stepIndex: Int)
fun handle(progress: Progress) = success().also {
stepKeyMap[progress]?.let { currentStep = it }
}
fun failure() {
// TODO: associate the exception with the step that just failed.
setCurrentStepStatus(StepStatus.FAILURE)
fun failure(error: Throwable) = updateCurrent(
State.FAILED,
error.stackTraceToString()
)
fun success() = updateCurrent(State.COMPLETED)
fun workData() = steps.serialize()
companion object {
/**
* A map of [Progress] to the corresponding position in [steps]
*/
private val stepKeyMap = mapOf(
Progress.Unpacking to StepKey(0, 1),
Progress.Merging to StepKey(0, 2),
Progress.PatchingStart to StepKey(1, 0),
Progress.Saving to StepKey(2, 0),
)
private fun generatePatchesStep(selectedPatches: List<String>) = Step(
R.string.patcher_step_group_patching,
selectedPatches.map { SubStep(it) }
)
fun generateSteps(context: Context, selectedPatches: List<String>) = mutableListOf(
Step(
R.string.patcher_step_group_prepare,
persistentListOf(
SubStep(context.getString(R.string.patcher_step_load_patches)),
SubStep(context.getString(R.string.patcher_step_unpack)),
SubStep(context.getString(R.string.patcher_step_integrations))
)
),
generatePatchesStep(selectedPatches),
Step(
R.string.patcher_step_group_saving,
persistentListOf(SubStep(context.getString(R.string.patcher_step_write_patched)))
)
)
}
fun success() {
setCurrentStepStatus(StepStatus.COMPLETED)
}
private data class StepKey(val step: Int, val substep: Int)
}

View File

@ -19,11 +19,11 @@ import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.deserialize
import app.revanced.manager.util.tag
import app.revanced.patcher.extensions.PatchExtensions.patchName
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
@ -44,7 +44,6 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
)
companion object {
const val ARGS_KEY = "args"
private const val logPrefix = "[Worker]:"
private fun String.logFmt() = "$logPrefix $this"
}
@ -76,7 +75,7 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
return Result.failure()
}
val args = Json.decodeFromString<Args>(inputData.getString(ARGS_KEY)!!)
val args = inputData.deserialize<Args>()!!
try {
// This does not always show up for some reason.
@ -105,29 +104,38 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
Aapt.binary(applicationContext)?.absolutePath
?: throw FileNotFoundException("Could not resolve aapt.")
val frameworkPath = applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
val frameworkPath =
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
val bundles = sourceRepository.bundles.first()
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
val patchList = args.selectedPatches.flatMap { (bundleName, selected) ->
bundles[bundleName]?.loadPatchesFiltered(args.packageName)
?.filter { selected.contains(it.patchName) }
?: throw IllegalArgumentException("Patch bundle $bundleName does not exist")
}
val progressManager =
PatcherProgressManager(applicationContext, patchList.map { it.patchName })
PatcherProgressManager(applicationContext, args.selectedPatches.flatMap { it.value })
suspend fun updateProgress(progress: Progress) {
progressManager.handle(progress)
setProgress(progressManager.groupsToWorkData())
setProgress(progressManager.workData())
}
updateProgress(Progress.Unpacking)
return try {
Session(applicationContext.cacheDir.absolutePath, frameworkPath, aaptPath, File(args.input)) {
val patchList = args.selectedPatches.flatMap { (bundleName, selected) ->
bundles[bundleName]?.loadPatchesFiltered(args.packageName)
?.filter { selected.contains(it.patchName) }
?: throw IllegalArgumentException("Patch bundle $bundleName does not exist")
}
// Ensure they are in the correct order so we can track progress properly.
progressManager.replacePatchesList(patchList.map { it.patchName })
updateProgress(Progress.Unpacking)
Session(
applicationContext.cacheDir.absolutePath,
frameworkPath,
aaptPath,
File(args.input)
) {
updateProgress(it)
}.use { session ->
session.run(File(args.output), patchList, integrations)
@ -135,11 +143,11 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
Log.i(tag, "Patching succeeded".logFmt())
progressManager.success()
Result.success(progressManager.groupsToWorkData())
Result.success(progressManager.workData())
} catch (e: Exception) {
Log.e(tag, "Got exception while patching".logFmt(), e)
progressManager.failure()
Result.failure(progressManager.groupsToWorkData())
progressManager.failure(e)
Result.failure(progressManager.workData())
}
}
}

View File

@ -0,0 +1,22 @@
package app.revanced.manager.ui.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@Composable
fun ArrowButton(expanded: Boolean, onClick: () -> Unit) {
IconButton(onClick = onClick) {
val (icon, string) = if (expanded) Icons.Filled.KeyboardArrowUp to R.string.collapse_content else Icons.Filled.KeyboardArrowDown to R.string.expand_content
Icon(
imageVector = icon,
contentDescription = stringResource(string)
)
}
}

View File

@ -5,7 +5,9 @@ import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
@ -32,12 +34,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.patcher.worker.StepGroup
import app.revanced.manager.patcher.worker.StepStatus
import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.patcher.worker.State
import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.viewmodel.InstallerViewModel
import app.revanced.manager.util.APK_MIMETYPE
import kotlin.math.exp
import kotlin.math.floor
@OptIn(ExperimentalMaterial3Api::class)
@ -46,9 +50,10 @@ fun InstallerScreen(
onBackClick: () -> Unit,
vm: InstallerViewModel
) {
val exportApkLauncher = rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
val patcherState by vm.patcherState.observeAsState(vm.initialState)
val canInstall by remember { derivedStateOf { patcherState.status == true && (vm.installedPackageName != null || !vm.isInstalling) } }
val canInstall by remember { derivedStateOf { patcherState.succeeded == true && (vm.installedPackageName != null || !vm.isInstalling) } }
AppScaffold(
topBar = {
@ -71,8 +76,8 @@ fun InstallerScreen(
.padding(paddingValues)
.fillMaxSize()
) {
patcherState.stepGroups.forEach {
InstallGroup(it)
patcherState.steps.forEach {
InstallStep(it)
}
Spacer(modifier = Modifier.weight(1f))
Row(
@ -103,7 +108,7 @@ fun InstallerScreen(
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@Composable
fun InstallGroup(group: StepGroup) {
fun InstallStep(step: Step) {
var expanded by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier
@ -122,48 +127,39 @@ fun InstallGroup(group: StepGroup) {
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp)
.run { if (expanded) {
background(MaterialTheme.colorScheme.secondaryContainer)
} else
background(MaterialTheme.colorScheme.surface)
}
.background(if (expanded) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
) {
StepIcon(group.status, 24.dp)
StepIcon(step.state, 24.dp)
Text(text = stringResource(group.name), style = MaterialTheme.typography.titleMedium)
Text(text = stringResource(step.name), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = { expanded = !expanded }) {
if (expanded) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "collapse"
)
} else {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "expand"
)
}
ArrowButton(expanded = expanded) {
expanded = !expanded
}
}
AnimatedVisibility(visible = expanded) {
val scrollState = rememberScrollState()
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(16.dp)
.padding(start = 4.dp)
) {
group.steps.forEach {
step.substeps.forEach {
var messageExpanded by rememberSaveable { mutableStateOf(true) }
val stacktrace = it.message
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
StepIcon(it.status, size = 18.dp)
StepIcon(it.state, size = 18.dp)
Text(
text = it.name,
@ -172,6 +168,20 @@ fun InstallGroup(group: StepGroup) {
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true),
)
if (stacktrace != null) {
ArrowButton(expanded = messageExpanded) {
messageExpanded = !messageExpanded
}
}
}
AnimatedVisibility(visible = messageExpanded && stacktrace != null) {
Text(
text = stacktrace ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
@ -180,31 +190,33 @@ fun InstallGroup(group: StepGroup) {
}
@Composable
fun StepIcon(status: StepStatus, size: Dp) {
fun StepIcon(status: State, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1)
when (status) {
StepStatus.COMPLETED -> Icon(
State.COMPLETED -> Icon(
Icons.Filled.CheckCircle,
contentDescription = "success",
contentDescription = stringResource(R.string.step_completed),
tint = MaterialTheme.colorScheme.surfaceTint,
modifier = Modifier.size(size)
)
StepStatus.FAILURE -> Icon(
State.FAILED -> Icon(
Icons.Filled.Cancel,
contentDescription = "failed",
contentDescription = stringResource(R.string.step_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
StepStatus.WAITING -> CircularProgressIndicator(
State.WAITING -> CircularProgressIndicator(
strokeWidth = strokeWidth,
modifier = Modifier
.size(size)
.semantics {
contentDescription = "waiting"
}
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
}
)
}
}

View File

@ -20,16 +20,16 @@ import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.R
import app.revanced.manager.patcher.worker.PatcherProgressManager
import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.patcher.worker.StepGroup
import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.util.AppInfo
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.deserialize
import app.revanced.manager.util.serialize
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
@ -56,26 +56,22 @@ class InstallerViewModel(
val appButtonText by derivedStateOf { if (installedPackageName == null) R.string.install_app else R.string.open_app }
private val workManager = WorkManager.getInstance(app)
private val patcherWorker =
OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).setInputData(
workDataOf(
PatcherWorker.ARGS_KEY to
Json.Default.encodeToString(
PatcherWorker.Args(
input.path!!.absolutePath,
outputFile.path,
selectedPatches,
input.packageName,
input.packageInfo!!.versionName,
)
)
)
PatcherWorker.Args(
input.path!!.absolutePath,
outputFile.path,
selectedPatches,
input.packageName,
input.packageInfo!!.versionName,
).serialize()
).build()
val initialState = PatcherState(
status = null,
stepGroups = PatcherProgressManager.generateGroupsList(
succeeded = null,
steps = PatcherProgressManager.generateSteps(
app,
selectedPatches.flatMap { (_, selected) -> selected }
)
@ -83,16 +79,16 @@ class InstallerViewModel(
val patcherState =
workManager.getWorkInfoByIdLiveData(patcherWorker.id).map { workInfo: WorkInfo ->
var status: Boolean? = null
val stepGroups = when (workInfo.state) {
val steps = when (workInfo.state) {
WorkInfo.State.RUNNING -> workInfo.progress
WorkInfo.State.FAILED, WorkInfo.State.SUCCEEDED -> workInfo.outputData.also {
status = workInfo.state == WorkInfo.State.SUCCEEDED
}
else -> null
}?.let { PatcherProgressManager.groupsFromWorkData(it) }
}?.deserialize<List<Step>>()
PatcherState(status, stepGroups ?: initialState.stepGroups)
PatcherState(status, steps ?: initialState.steps)
}
private val installBroadcastReceiver = object : BroadcastReceiver() {
@ -170,6 +166,5 @@ class InstallerViewModel(
}
}
data class PatcherState(val status: Boolean?, val stepGroups: List<StepGroup>)
data class PatcherState(val succeeded: Boolean?, val steps: List<Step>)
}

View File

@ -12,9 +12,15 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.work.Data
import androidx.work.workDataOf
import io.ktor.http.Url
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
typealias PatchesSelection = Map<String, List<String>>
@ -55,7 +61,12 @@ inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, bl
try {
block()
} catch (error: Exception) {
context.toast(context.getString(toastMsg, error.message ?: error.cause?.message ?: error::class.simpleName))
context.toast(
context.getString(
toastMsg,
error.message ?: error.cause?.message ?: error::class.simpleName
)
)
Log.e(tag, logMsg, error)
}
}
@ -69,4 +80,14 @@ inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle(
block()
}
}
}
}
const val workDataKey = "payload"
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> T.serialize(): Data =
workDataOf(workDataKey to Cbor.Default.encodeToByteArray(this))
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> Data.deserialize(): T? =
getByteArray(workDataKey)?.let { Cbor.Default.decodeFromByteArray(it) }

View File

@ -85,6 +85,7 @@
<string name="sign_fail">Failed to sign Apk: %s</string>
<string name="patcher_step_group_prepare">Preparation</string>
<string name="patcher_step_load_patches">Load patches</string>
<string name="patcher_step_unpack">Unpack Apk</string>
<string name="patcher_step_integrations">Merge Integrations</string>
<string name="patcher_step_group_patching">Patching</string>
@ -92,6 +93,13 @@
<string name="patcher_step_write_patched">Write patched Apk</string>
<string name="patcher_notification_message">Patching in progress…</string>
<string name="step_completed">completed</string>
<string name="step_failed">failed</string>
<string name="step_running">running</string>
<string name="expand_content">expand</string>
<string name="collapse_content">collapse</string>
<string name="more">More</string>
<string name="donate">Donate</string>
<string name="website">Website</string>