Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 920d6bff authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

debug: Add database dumper

 - can be fetched by clicking 7 times on app version in settings
 - database is saved in /downloads/applounge/${timestamp}.zip
parent 4d7bbae5
Loading
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -22,10 +22,12 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import foundation.e.apps.data.debug.DatabaseDumpRepositoryImpl
import foundation.e.apps.data.preference.AppLoungePreference
import foundation.e.apps.data.preference.PlayStoreAuthStore
import foundation.e.apps.data.preference.SessionDataStore
import foundation.e.apps.data.preference.updateinterval.UpdatePreferencesRepositoryImpl
import foundation.e.apps.domain.debug.DatabaseDumpRepository
import foundation.e.apps.domain.login.PlayStoreCredentialsRepository
import foundation.e.apps.domain.preferences.AppPreferencesRepository
import foundation.e.apps.domain.preferences.SessionRepository
@@ -54,6 +56,12 @@ interface PreferenceBindingModule {
        updatePreferencesRepositoryImpl: UpdatePreferencesRepositoryImpl
    ): UpdatePreferencesRepository

    @Binds
    @Singleton
    fun bindDatabaseDumpRepository(
        databaseDumpRepositoryImpl: DatabaseDumpRepositoryImpl
    ): DatabaseDumpRepository

    @Binds
    @Singleton
    fun bindPlayStoreAuthStore(
+54 −2
Original line number Diff line number Diff line
@@ -26,8 +26,11 @@ import android.view.View
import android.widget.Toast
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.findNavController
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
@@ -52,6 +55,7 @@ import foundation.e.apps.databinding.CustomPreferenceBinding
import foundation.e.apps.domain.model.User
import foundation.e.apps.domain.preferences.SessionRepository
import foundation.e.apps.ui.LoginViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
@@ -66,6 +70,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
    private var showPWAApplications: CheckBoxPreference? = null
    private var troubleShootPreference: Preference? = null

    private val settingsViewModel: SettingsViewModel by viewModels()

    private val loginViewModel: LoginViewModel by lazy {
        ViewModelProvider(requireActivity())[LoginViewModel::class.java]
    }
@@ -122,6 +128,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
        val versionInfo = findPreference<LongPressPreference>("versionInfo")
        versionInfo?.apply {
            summary = BuildConfig.VERSION_NAME
            setOnPreferenceClickListener {
                settingsViewModel.onVersionInfoTapped()
                true
            }
            setOnLongClickListener {
                val osVersion = SystemInfoProvider.getSystemProperty(SystemInfoProvider.KEY_LINEAGE_VERSION)
                val appVersionLabel = getString(R.string.app_version_label)
@@ -150,6 +160,49 @@ class SettingsFragment : PreferenceFragmentCompat() {
        troubleShootPreference?.intent = Intent(Intent.ACTION_VIEW, troubleshootUrl.toUri())
    }

    private fun observeSettingsEffects() {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                settingsViewModel.effect.collectLatest(::handleSettingsEffect)
            }
        }
    }

    private fun handleSettingsEffect(effect: SettingsUiEffect) {
        when (effect) {
            SettingsUiEffect.DatabaseDumpStarted -> {
                Toast.makeText(requireContext(), R.string.database_dump_started, Toast.LENGTH_SHORT)
                    .show()
            }

            SettingsUiEffect.DatabaseDumpInProgress -> {
                Toast.makeText(
                    requireContext(),
                    R.string.database_dump_in_progress,
                    Toast.LENGTH_SHORT
                ).show()
            }

            is SettingsUiEffect.DatabaseDumpSucceeded -> {
                Toast.makeText(
                    requireContext(),
                    resources.getQuantityString(
                        R.plurals.database_dump_success,
                        effect.databaseCount,
                        effect.databaseCount,
                        effect.relativeOutputPath
                    ),
                    Toast.LENGTH_LONG
                ).show()
            }

            SettingsUiEffect.DatabaseDumpFailed -> {
                Toast.makeText(requireContext(), R.string.database_dump_failed, Toast.LENGTH_LONG)
                    .show()
            }
        }
    }

    /**
     * Checkbox listener to prevent all checkboxes from getting unchecked.
     */
@@ -200,8 +253,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = CustomPreferenceBinding.bind(view)

        super.onViewCreated(view, savedInstanceState)
        observeSettingsEffects()

        val autoInstallUpdate = fetchCheckboxPreference(R.string.auto_install_enabled)
        val onlyUnmeteredNetwork = fetchCheckboxPreference(R.string.only_unmetered_network)
+116 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps.ui.settings

import android.os.SystemClock
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import foundation.e.apps.domain.debug.ExportDatabaseDumpUseCase
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
import javax.inject.Inject

@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val exportDatabaseDumpUseCase: ExportDatabaseDumpUseCase,
) : ViewModel() {

    private val effectChannel = Channel<SettingsUiEffect>(capacity = Channel.BUFFERED)
    val effect = effectChannel.receiveAsFlow()

    private var versionInfoTapCount = 0
    private var lastVersionInfoTapAt = 0L
    private var isDatabaseDumpInProgress = false

    fun onVersionInfoTapped() {
        if (isDatabaseDumpInProgress) {
            viewModelScope.launch {
                effectChannel.send(SettingsUiEffect.DatabaseDumpInProgress)
            }
            return
        }

        val now = SystemClock.elapsedRealtime()
        if (now - lastVersionInfoTapAt > VERSION_INFO_TAP_TIMEOUT_MS) {
            versionInfoTapCount = 0
        }

        lastVersionInfoTapAt = now
        versionInfoTapCount += 1

        val remainingTaps = DATABASE_DUMP_TRIGGER_TAP_COUNT - versionInfoTapCount
        if (remainingTaps > 0) {
            return
        }

        versionInfoTapCount = 0
        lastVersionInfoTapAt = 0L
        exportDatabases()
    }

    private fun exportDatabases() {
        isDatabaseDumpInProgress = true
        viewModelScope.launch {
            effectChannel.send(SettingsUiEffect.DatabaseDumpStarted)
            try {
                val result = withContext(Dispatchers.IO) {
                    exportDatabaseDumpUseCase()
                }
                effectChannel.send(
                    SettingsUiEffect.DatabaseDumpSucceeded(
                        databaseCount = result.databaseCount,
                        relativeOutputPath = result.relativeOutputPath,
                    )
                )
            } catch (exception: CancellationException) {
                throw exception
            } catch (exception: IOException) {
                Timber.e(exception, "Failed to export databases from version info tap")
                effectChannel.send(SettingsUiEffect.DatabaseDumpFailed)
            } catch (exception: SecurityException) {
                Timber.e(exception, "Failed to export databases from version info tap")
                effectChannel.send(SettingsUiEffect.DatabaseDumpFailed)
            } finally {
                isDatabaseDumpInProgress = false
            }
        }
    }

    private companion object {
        private const val DATABASE_DUMP_TRIGGER_TAP_COUNT = 7
        private const val VERSION_INFO_TAP_TIMEOUT_MS = 3_000L
    }
}

sealed interface SettingsUiEffect {
    data object DatabaseDumpStarted : SettingsUiEffect
    data object DatabaseDumpInProgress : SettingsUiEffect
    data class DatabaseDumpSucceeded(
        val databaseCount: Int,
        val relativeOutputPath: String,
    ) : SettingsUiEffect

    data object DatabaseDumpFailed : SettingsUiEffect
}
+10 −0
Original line number Diff line number Diff line
@@ -205,6 +205,16 @@
    <string name="timeout_desc_cleanapk">Some network issue is preventing fetching all applications.</string>
    <string name="open_settings">Open Settings</string>
    <string name="more_info"><u>More info</u></string>

    <!-- Database Dump -->
    <string name="database_dump_started">Exporting app databases to Downloads...</string>
    <plurals name="database_dump_success">
        <item quantity="one">Exported %1$d database to %2$s</item>
        <item quantity="other">Exported %1$d databases to %2$s</item>
    </plurals>
    <string name="database_dump_failed">Database export failed.</string>
    <string name="database_dump_in_progress">Database export is already running.</string>

    <!--Too many request-->
    <string name="too_many_requests_desc">The anonymous account you\'re currently using is unavailable. Please refresh the session to get another one.</string>
    <string name="account_unavailable">Account unavailable</string>
+81 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package foundation.e.apps.data.debug

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class DatabaseDumpArchiveWriter @Inject constructor(
    @ApplicationContext private val context: Context,
) {

    fun getDatabaseNames(): List<String> {
        return filterPrimaryDatabaseNames(context.databaseList().toList())
    }

    fun filterPrimaryDatabaseNames(databaseNames: List<String>): List<String> {
        return databaseNames
            .filterNot(::isSidecarDatabaseFile)
            .sorted()
    }

    fun writeArchive(outputStream: OutputStream, databaseNames: List<String>): Int {
        return ZipOutputStream(outputStream.buffered()).use { zipOutputStream ->
            databaseNames.sumOf { databaseName ->
                writeDatabaseFiles(zipOutputStream, databaseName)
            }
        }
    }

    private fun writeDatabaseFiles(zipOutputStream: ZipOutputStream, databaseName: String): Int {
        val databaseFile = context.getDatabasePath(databaseName)
        if (!databaseFile.exists()) {
            return 0
        }

        return DATABASE_FILE_SUFFIXES.count { suffix ->
            val sourceFile = File("${databaseFile.absolutePath}$suffix")
            if (!sourceFile.exists()) {
                return@count false
            }

            sourceFile.inputStream().buffered().use { inputStream ->
                zipOutputStream.putNextEntry(ZipEntry(sourceFile.name))
                inputStream.copyTo(zipOutputStream)
                zipOutputStream.closeEntry()
            }
            true
        }
    }

    private fun isSidecarDatabaseFile(databaseName: String): Boolean {
        return DATABASE_FILE_SUFFIXES_WITHOUT_BASE.any(databaseName::endsWith)
    }

    private companion object {
        private val DATABASE_FILE_SUFFIXES = listOf("", "-wal", "-shm", "-journal")
        private val DATABASE_FILE_SUFFIXES_WITHOUT_BASE = DATABASE_FILE_SUFFIXES.drop(1)
    }
}
Loading