diff --git a/src/main/kotlin/com/looker/droidify/ui/activities/PrefsActivityX.kt b/src/main/kotlin/com/looker/droidify/ui/activities/PrefsActivityX.kt new file mode 100644 index 00000000..175acc00 --- /dev/null +++ b/src/main/kotlin/com/looker/droidify/ui/activities/PrefsActivityX.kt @@ -0,0 +1,197 @@ +package com.looker.droidify.ui.activities + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.* +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.navigateUp +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.appbar.MaterialToolbar +import com.looker.droidify.BuildConfig +import com.looker.droidify.ContextWrapperX +import com.looker.droidify.MainApplication +import com.looker.droidify.R +import com.looker.droidify.content.Preferences +import com.looker.droidify.database.CursorOwner +import com.looker.droidify.databinding.ActivityPrefsXBinding +import com.looker.droidify.installer.AppInstaller +import com.looker.droidify.service.Connection +import com.looker.droidify.service.SyncService +import com.looker.droidify.ui.fragments.MainNavFragmentX +import com.looker.droidify.ui.fragments.Source +import com.looker.droidify.utility.extension.text.nullIfEmpty +import kotlinx.coroutines.launch + +// TODO clean up the bloat +class PrefsActivityX : AppCompatActivity() { + companion object { + const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES" + const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL" + const val EXTRA_CACHE_FILE_NAME = + "${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME" + } + + sealed class SpecialIntent { + object Updates : SpecialIntent() + class Install(val packageName: String?, val cacheFileName: String?) : SpecialIntent() + } + + lateinit var binding: ActivityPrefsXBinding + lateinit var toolbar: MaterialToolbar + lateinit var appBarConfiguration: AppBarConfiguration + private lateinit var navController: NavController + + private val syncConnection = Connection(SyncService::class.java, onBind = { _, _ -> + navController.currentDestination?.let { + val source = Source.values()[when (it.id) { + R.id.updateTab -> 1 + R.id.otherTab -> 2 + else -> 0 // R.id.userTab + }] + updateUpdateNotificationBlocker(source) + } + }) + + val db + get() = (application as MainApplication).db + + lateinit var cursorOwner: CursorOwner + private set + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(Preferences[Preferences.Key.Theme].getResId(resources.configuration)) + super.onCreate(savedInstanceState) + + binding = ActivityPrefsXBinding.inflate(layoutInflater) + binding.lifecycleOwner = this + toolbar = binding.toolbar + + if (savedInstanceState == null && (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) { + handleIntent(intent) + } + setContentView(binding.root) + + if (savedInstanceState == null) { + cursorOwner = CursorOwner() + supportFragmentManager.beginTransaction() + .add(cursorOwner, CursorOwner::class.java.name) + .commit() + } else { + cursorOwner = supportFragmentManager + .findFragmentByTag(CursorOwner::class.java.name) as CursorOwner + } + + setSupportActionBar(toolbar) + + toolbar.isFocusableInTouchMode = true + binding.collapsingToolbar.title = getString(R.string.settings) + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_content) as NavHostFragment + navController = navHostFragment.navController + binding.bottomNavigation.setupWithNavController(navController) + + appBarConfiguration = AppBarConfiguration( + setOf(R.id.exploreTab, R.id.latestTab, R.id.installedTab) + ) + setupActionBarWithNavController(navController, appBarConfiguration) + + supportFragmentManager.addFragmentOnAttachListener { _, _ -> + hideKeyboard() + } + } + + override fun onStart() { + super.onStart() + syncConnection.bind(this) + } + + override fun onSupportNavigateUp(): Boolean { + return navController.navigateUp(appBarConfiguration) + } + + private fun hideKeyboard() { + (getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager) + ?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private val Intent.packageName: String? + get() { + val uri = data + return when { + uri?.scheme == "package" || uri?.scheme == "fdroid.app" -> { + uri.schemeSpecificPart?.nullIfEmpty() + } + uri?.scheme == "market" && uri.host == "details" -> { + uri.getQueryParameter("id")?.nullIfEmpty() + } + uri != null && uri.scheme in setOf("http", "https") -> { + val host = uri.host.orEmpty() + if (host == "f-droid.org" || host.endsWith(".f-droid.org")) { + uri.lastPathSegment?.nullIfEmpty() + } else { + null + } + } + else -> { + null + } + } + } + + private fun handleSpecialIntent(specialIntent: SpecialIntent) { + when (specialIntent) { + is SpecialIntent.Updates -> navController.navigate(R.id.installedTab) + is SpecialIntent.Install -> { + val packageName = specialIntent.packageName + if (!packageName.isNullOrEmpty()) { + lifecycleScope.launch { + specialIntent.cacheFileName?.let { + AppInstaller.getInstance(this@PrefsActivityX) + ?.defaultInstaller?.install(packageName, it) + } + } + } + Unit + } + }::class + } + + private fun handleIntent(intent: Intent?) { + when (intent?.action) { + ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates) + ACTION_INSTALL -> handleSpecialIntent( + SpecialIntent.Install( + intent.packageName, + intent.getStringExtra(EXTRA_CACHE_FILE_NAME) + ) + ) + } + } + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(ContextWrapperX.wrap(newBase)) + } + + private fun updateUpdateNotificationBlocker(activeSource: Source) { + val blockerFragment = if (activeSource == Source.UPDATES) { + supportFragmentManager.fragments.asSequence().mapNotNull { it as? MainNavFragmentX } + .find { it.source == activeSource } + } else { + null + } + syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment) + } +} diff --git a/src/main/kotlin/com/looker/droidify/ui/fragments/PrefsNavFragmentX.kt b/src/main/kotlin/com/looker/droidify/ui/fragments/PrefsNavFragmentX.kt new file mode 100644 index 00000000..98d949c7 --- /dev/null +++ b/src/main/kotlin/com/looker/droidify/ui/fragments/PrefsNavFragmentX.kt @@ -0,0 +1,356 @@ +package com.looker.droidify.ui.fragments + +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.net.toUri +import androidx.core.widget.NestedScrollView +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.google.android.material.circularreveal.CircularRevealFrameLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textview.MaterialTextView +import com.looker.droidify.R +import com.looker.droidify.content.Preferences +import com.looker.droidify.databinding.FragmentPrefsBinding +import com.looker.droidify.databinding.PreferenceItemBinding +import com.looker.droidify.utility.extension.resources.* +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.launch + +abstract class PrefsNavFragmentX : Fragment() { + private lateinit var binding: FragmentPrefsBinding + private var preferenceBinding: PreferenceItemBinding? = null + private val preferences = mutableMapOf, Preference<*>>() + + override fun onResume() { + super.onResume() + preferences.forEach { (_, preference) -> preference.update() } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + super.onCreate(savedInstanceState) + binding = FragmentPrefsBinding.inflate(inflater, container, false) + binding.lifecycleOwner = this + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + preferenceBinding = PreferenceItemBinding.inflate(layoutInflater) + + val content = binding.fragmentContent + val scroll = NestedScrollView(content.context) + scroll.id = R.id.preferences_list + scroll.isFillViewport = true + content.addView( + scroll, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + val scrollLayout = CircularRevealFrameLayout(content.context) + scroll.addView( + scrollLayout, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + + setupPrefs(scrollLayout) + + lifecycleScope.launch { + Preferences.subject + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .collect { updatePreference(it) } + } + updatePreference(null) + } + + abstract fun setupPrefs(scrollLayout: CircularRevealFrameLayout) + + private fun openURI(url: Uri) { + startActivity(Intent(Intent.ACTION_VIEW, url)) + } + + override fun onDestroyView() { + super.onDestroyView() + preferences.clear() + preferenceBinding = null + } + + private fun updatePreference(key: Preferences.Key<*>?) { + if (key != null) { + preferences[key]?.update() + } + if (key == null || key == Preferences.Key.ProxyType) { + val enabled = when (Preferences[Preferences.Key.ProxyType]) { + is Preferences.ProxyType.Direct -> false + is Preferences.ProxyType.Http, is Preferences.ProxyType.Socks -> true + } + preferences[Preferences.Key.ProxyHost]?.setEnabled(enabled) + preferences[Preferences.Key.ProxyPort]?.setEnabled(enabled) + } + if (key == Preferences.Key.RootPermission) { + preferences[Preferences.Key.RootPermission]?.setEnabled( + Shell.getCachedShell()?.isRoot + ?: Shell.getShell().isRoot + ) + } + if (key == Preferences.Key.Theme) { + requireActivity().recreate() + } + } + + protected fun LinearLayoutCompat.addText(title: String, summary: String, url: String) { + val text = MaterialTextView(context) + val subText = MaterialTextView(context) + text.text = title + subText.text = summary + text.setTextSizeScaled(16) + subText.setTextSizeScaled(14) + resources.sizeScaled(16).let { + text.setPadding(it, it, 5, 5) + subText.setPadding(it, 5, 5, 25) + } + addView( + text, + LinearLayoutCompat.LayoutParams.MATCH_PARENT, + LinearLayoutCompat.LayoutParams.WRAP_CONTENT + ) + addView( + subText, + LinearLayoutCompat.LayoutParams.MATCH_PARENT, + LinearLayoutCompat.LayoutParams.WRAP_CONTENT + ) + text.setOnClickListener { openURI(url.toUri()) } + subText.setOnClickListener { openURI(url.toUri()) } + } + + protected inline fun LinearLayoutCompat.addCategory( + title: String, + callback: LinearLayoutCompat.() -> Unit, + ) { + val text = MaterialTextView(context) + text.typeface = TypefaceExtra.medium + text.setTextSizeScaled(14) + text.setTextColor(text.context.getColorFromAttr(R.attr.colorPrimary)) + text.text = title + resources.sizeScaled(16).let { text.setPadding(it, it, it, 0) } + addView( + text, + LinearLayoutCompat.LayoutParams.MATCH_PARENT, + LinearLayoutCompat.LayoutParams.WRAP_CONTENT + ) + callback() + } + + protected fun LinearLayoutCompat.addPreference( + key: Preferences.Key, title: String, + summaryProvider: () -> String, dialogProvider: ((Context) -> AlertDialog)?, + ): Preference { + val preference = + Preference(key, this@PrefsNavFragmentX, this, title, summaryProvider, dialogProvider) + preferences[key] = preference + return preference + } + + protected fun LinearLayoutCompat.addSwitch( + key: Preferences.Key, + title: String, + summary: String, + ) { + val preference = addPreference(key, title, { summary }, null) + preference.check.visibility = View.VISIBLE + preference.view.setOnClickListener { Preferences[key] = !Preferences[key] } + preference.setCallback { preference.check.isChecked = Preferences[key] } + } + + protected fun LinearLayoutCompat.addEdit( + key: Preferences.Key, title: String, valueToString: (T) -> String, + stringToValue: (String) -> T?, configureEdit: (TextInputEditText) -> Unit, + ) { + addPreference(key, title, { valueToString(Preferences[key]) }) { it -> + val scroll = NestedScrollView(it) + scroll.resources.sizeScaled(20).let { scroll.setPadding(it, 0, it, 0) } + val edit = TextInputEditText(it) + configureEdit(edit) + edit.id = android.R.id.edit + edit.resources.sizeScaled(16) + .let { edit.setPadding(edit.paddingLeft, it, edit.paddingRight, it) } + edit.setText(valueToString(Preferences[key])) + edit.hint = edit.text.toString() + edit.text?.let { editable -> edit.setSelection(editable.length) } + edit.requestFocus() + scroll.addView( + edit, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + MaterialAlertDialogBuilder(it) + .setTitle(title) + .setView(scroll) + .setPositiveButton(R.string.ok) { _, _ -> + val value = stringToValue(edit.text.toString()) ?: key.default.value + post { Preferences[key] = value } + } + .setNegativeButton(R.string.cancel, null) + .create() + .apply { + window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + } + } + } + + protected fun LinearLayoutCompat.addEditString(key: Preferences.Key, title: String) { + addEdit(key, title, { it }, { it }, { }) + } + + protected fun LinearLayoutCompat.addEditInt( + key: Preferences.Key, + title: String, + range: IntRange?, + ) { + addEdit(key, title, { it.toString() }, { it.toIntOrNull() }) { + it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL + if (range != null) it.filters = + arrayOf(InputFilter { source, start, end, dest, dstart, dend -> + val value = (dest.substring(0, dstart) + source.substring(start, end) + + dest.substring(dend, dest.length)).toIntOrNull() + if (value != null && value in range) null else "" + }) + } + } + + protected fun > LinearLayoutCompat.addEnumeration( + key: Preferences.Key, + title: String, + valueToString: (T) -> String, + ) { + addPreference(key, title, { valueToString(Preferences[key]) }) { + val values = key.default.value.values + MaterialAlertDialogBuilder(it) + .setTitle(title) + .setSingleChoiceItems( + values.map(valueToString).toTypedArray(), + values.indexOf(Preferences[key]) + ) { dialog, which -> + dialog.dismiss() + post { Preferences[key] = values[which] } + } + .setNegativeButton(R.string.cancel, null) + .create() + } + } + + protected fun LinearLayoutCompat.addList( + key: Preferences.Key, + title: String, + values: List, + valueToString: (T) -> String, + ) { + addPreference(key, title, { valueToString(Preferences[key]) }) { + MaterialAlertDialogBuilder(it) + .setTitle(title) + .setSingleChoiceItems( + values.map(valueToString).toTypedArray(), + values.indexOf(Preferences[key]) + ) { dialog, which -> + dialog.dismiss() + post { Preferences[key] = values[which] } + } + .setNegativeButton(R.string.cancel, null) + .create() + } + } + + protected class Preference( + private val key: Preferences.Key, + fragment: Fragment, + parent: ViewGroup, + titleText: String, + private val summaryProvider: () -> String, + private val dialogProvider: ((Context) -> AlertDialog)?, + ) { + val view = parent.inflate(R.layout.preference_item) + val title = view.findViewById(R.id.title)!! + val summary = view.findViewById(R.id.summary)!! + val check = view.findViewById(R.id.check)!! + + private var callback: (() -> Unit)? = null + + init { + title.text = titleText + parent.addView(view) + if (dialogProvider != null) { + view.setOnClickListener { + PreferenceDialog(key.name) + .show( + fragment.childFragmentManager, + "${PreferenceDialog::class.java.name}.${key.name}" + ) + } + } + update() + } + + fun setCallback(callback: () -> Unit) { + this.callback = callback + update() + } + + fun setEnabled(enabled: Boolean) { + view.isEnabled = enabled + title.isEnabled = enabled + summary.isEnabled = enabled + check.isEnabled = enabled + } + + fun update() { + summary.text = summaryProvider() + summary.visibility = if (summary.text.isNotEmpty()) View.VISIBLE else View.GONE + callback?.invoke() + } + + fun createDialog(context: Context): AlertDialog { + return dialogProvider!!(context) + } + } + + class PreferenceDialog() : DialogFragment() { + companion object { + private const val EXTRA_KEY = "key" + } + + constructor(key: String) : this() { + arguments = Bundle().apply { + putString(EXTRA_KEY, key) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val preferences = (parentFragment as PrefsNavFragmentX).preferences + val key = requireArguments().getString(EXTRA_KEY)!! + .let { name -> preferences.keys.find { it.name == name }!! } + val preference = preferences[key]!! + return preference.createDialog(requireContext()) + } + } +}