/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai
*
* *
* 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 . *
* *
******************************************************************************/
package io.nekohasekai.sagernet.ui
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.component1
import androidx.activity.result.component2
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.LayoutRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.preference.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sagernet.GroupType
import io.nekohasekai.sagernet.Key
import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.SubscriptionType
import io.nekohasekai.sagernet.database.*
import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener
import io.nekohasekai.sagernet.ktx.Logs
import io.nekohasekai.sagernet.ktx.applyDefaultValues
import io.nekohasekai.sagernet.ktx.onMainDispatcher
import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
import io.nekohasekai.sagernet.ui.profile.ProfileSettingsActivity.PasswordSummaryProvider
@Suppress("UNCHECKED_CAST")
class GroupSettingsActivity(
@LayoutRes resId: Int = R.layout.layout_config_settings,
) : ThemedActivity(resId),
OnPreferenceDataStoreChangeListener {
var dirty = false
override val onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
override fun handleOnBackPressed() {
MaterialAlertDialogBuilder(this@GroupSettingsActivity)
.setTitle(R.string.unsaved_changes_prompt)
.setPositiveButton(android.R.string.ok) { _, _ ->
runOnDefaultDispatcher {
saveAndExit()
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
finish()
}
.show()
}
}
private lateinit var frontProxyPreference: ListPreference
private lateinit var landingProxyPreference: ListPreference
fun ProxyGroup.init() {
DataStore.groupName = name ?: ""
DataStore.groupType = type
DataStore.groupOrder = order
val sub = if (type == GroupType.SUBSCRIPTION) {
subscription ?: SubscriptionBean().applyDefaultValues()
} else {
SubscriptionBean().applyDefaultValues()
}
DataStore.subscriptionType = sub.type
DataStore.subscriptionLink = sub.link
DataStore.subscriptionDeduplication = sub.deduplication
DataStore.subscriptionUpdateWhenConnectedOnly = sub.updateWhenConnectedOnly
DataStore.subscriptionUserAgent = sub.customUserAgent
DataStore.subscriptionAutoUpdate = sub.autoUpdate
DataStore.subscriptionAutoUpdateDelay = sub.autoUpdateDelay
DataStore.subscriptionLastUpdated = sub.lastUpdated
DataStore.subscriptionBytesUsed = sub.bytesUsed
DataStore.subscriptionBytesRemaining = sub.bytesRemaining
DataStore.subscriptionExpiryDate = sub.expiryDate
DataStore.subscriptionNameFilter = sub.nameFilter
DataStore.subscriptionNameFilter1 = sub.nameFilter1
DataStore.subscriptionHTTPHeaders = sub.httpHeaders
DataStore.subscriptionAgePrivateKey = sub.agePrivateKey
DataStore.frontProxyOutbound = frontProxy
DataStore.landingProxyOutbound = landingProxy
DataStore.frontProxy = if (frontProxy >= 0) 1 else 0
DataStore.landingProxy = if (landingProxy >= 0) 1 else 0
}
fun ProxyGroup.serialize() {
type = DataStore.groupType
name = DataStore.groupName.takeIf { it.isNotEmpty() } ?:
if (type == GroupType.SUBSCRIPTION) {
getString(R.string.subscription)
} else {
getString(R.string.menu_group)
}
order = DataStore.groupOrder
frontProxy = if (DataStore.frontProxy == 1) DataStore.frontProxyOutbound else -1
landingProxy = if (DataStore.landingProxy == 1) DataStore.landingProxyOutbound else -1
if (type == GroupType.SUBSCRIPTION) {
subscription = SubscriptionBean().applyDefaultValues().apply {
type = DataStore.subscriptionType
link = DataStore.subscriptionLink
deduplication = DataStore.subscriptionDeduplication
updateWhenConnectedOnly = DataStore.subscriptionUpdateWhenConnectedOnly
customUserAgent = DataStore.subscriptionUserAgent
autoUpdate = DataStore.subscriptionAutoUpdate
autoUpdateDelay = DataStore.subscriptionAutoUpdateDelay
lastUpdated = DataStore.subscriptionLastUpdated
bytesUsed = DataStore.subscriptionBytesUsed
bytesRemaining = DataStore.subscriptionBytesRemaining
expiryDate = DataStore.subscriptionExpiryDate
nameFilter = DataStore.subscriptionNameFilter
nameFilter1 = DataStore.subscriptionNameFilter1
httpHeaders = DataStore.subscriptionHTTPHeaders
agePrivateKey = DataStore.subscriptionAgePrivateKey
}
} else {
subscription = SubscriptionBean().applyDefaultValues()
}
}
fun needSave(): Boolean {
return dirty
}
fun PreferenceFragmentCompat.createPreferences(
savedInstanceState: Bundle?,
rootKey: String?,
) {
addPreferencesFromResource(R.xml.group_preferences)
frontProxyPreference = findPreference(Key.GROUP_FRONT_PROXY)!!
if (DataStore.frontProxy == 1) {
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android16-s2-release/core/java/android/preference/ListPreference.java#167
// If the summary has a {@linkplain java.lang.String#format String formatting} marker in it,
// (i.e. "%s" or "%1$s"), then the current entry value will be substituted in its place.
frontProxyPreference.setSummary(ProfileManager.getProfile(DataStore.frontProxyOutbound)?.displayName()?.replace("%", "%%"))
} else {
frontProxyPreference.setSummary(resources.getString(R.string.disable))
}
frontProxyPreference.apply {
setOnPreferenceChangeListener { _, newValue ->
if (newValue.toString() == "1") {
selectProfileForAddFront.launch(
Intent(this@GroupSettingsActivity, ProfileSelectActivity::class.java)
)
false
} else {
setSummary(resources.getString(R.string.disable))
true
}
}
}
landingProxyPreference = findPreference(Key.GROUP_LANDING_PROXY)!!
if (DataStore.landingProxy == 1) {
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android16-s2-release/core/java/android/preference/ListPreference.java#167
// If the summary has a {@linkplain java.lang.String#format String formatting} marker in it,
// (i.e. "%s" or "%1$s"), then the current entry value will be substituted in its place.
landingProxyPreference.setSummary(ProfileManager.getProfile(DataStore.landingProxyOutbound)?.displayName()?.replace("%", "%%"))
} else {
landingProxyPreference.setSummary(resources.getString(R.string.disable))
}
landingProxyPreference.apply {
setOnPreferenceChangeListener { _, newValue ->
if (newValue.toString() == "1") {
selectProfileForAddLanding.launch(
Intent(this@GroupSettingsActivity, ProfileSelectActivity::class.java)
)
false
} else {
setSummary(resources.getString(R.string.disable))
true
}
}
}
val groupType = findPreference(Key.GROUP_TYPE)!!
val groupSubscription = findPreference(Key.GROUP_SUBSCRIPTION)!!
val subscriptionUpdate = findPreference(Key.SUBSCRIPTION_UPDATE)!!
val subscriptionType = findPreference(Key.SUBSCRIPTION_TYPE)!!
val httpHeaders = findPreference(Key.SUBSCRIPTION_HTTP_HEADERS)!!.apply {
dialogMessage = getString(R.string.format, "\nKey1: Value1\nKey2: Value2")
}
val agePrivateKey = findPreference(Key.SUBSCRIPTION_AGE_PRIVATE_KEY)!!.apply {
summaryProvider = PasswordSummaryProvider
}
fun updateGroupType(groupType: Int = DataStore.groupType) {
val isSubscription = groupType == GroupType.SUBSCRIPTION
groupSubscription.isVisible = isSubscription
subscriptionUpdate.isVisible = isSubscription
httpHeaders.isVisible = isSubscription
agePrivateKey.isVisible = isSubscription && (subscriptionType.value as String).toInt() == SubscriptionType.AGE
}
updateGroupType()
groupType.setOnPreferenceChangeListener { _, newValue ->
updateGroupType((newValue as String).toInt())
true
}
fun updateSubscriptionType(subscriptionType: Int = DataStore.subscriptionType) {
agePrivateKey.isVisible = subscriptionType == SubscriptionType.AGE
}
updateSubscriptionType()
subscriptionType.setOnPreferenceChangeListener { _, newValue ->
updateSubscriptionType((newValue as String).toInt())
true
}
val subscriptionAutoUpdate = findPreference(Key.SUBSCRIPTION_AUTO_UPDATE)!!
val subscriptionAutoUpdateDelay = findPreference(Key.SUBSCRIPTION_AUTO_UPDATE_DELAY)!!
subscriptionAutoUpdateDelay.isEnabled = subscriptionAutoUpdate.isChecked
subscriptionAutoUpdateDelay.setOnPreferenceChangeListener { _, newValue ->
newValue as String
newValue.toIntOrNull() != null && newValue.toInt() >= 15
}
subscriptionAutoUpdate.setOnPreferenceChangeListener { _, newValue ->
subscriptionAutoUpdateDelay.isEnabled = (newValue as Boolean)
true
}
}
companion object {
const val EXTRA_GROUP_ID = "id"
const val KEY_DIRTY = "dirty"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(findViewById(R.id.toolbar))
supportActionBar?.apply {
setTitle(R.string.group_settings)
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_navigation_close)
}
if (savedInstanceState == null) {
val editingId = intent.getLongExtra(EXTRA_GROUP_ID, 0L)
DataStore.editingId = editingId
runOnDefaultDispatcher {
if (editingId == 0L) {
ProxyGroup().init()
} else {
val entity = SagerDatabase.groupDao.getById(editingId)
if (entity == null) {
onMainDispatcher {
finish()
}
return@runOnDefaultDispatcher
}
entity.init()
}
onMainDispatcher {
supportFragmentManager.beginTransaction()
.replace(R.id.settings, MyPreferenceFragmentCompat())
.commit()
DataStore.profileCacheStore.registerChangeListener(this@GroupSettingsActivity)
}
}
} else {
savedInstanceState.getBoolean(KEY_DIRTY).let {
dirty = it
onBackPressedCallback.isEnabled = it
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(KEY_DIRTY, dirty)
}
suspend fun saveAndExit() {
val editingId = DataStore.editingId
if (editingId == 0L) {
GroupManager.createGroup(ProxyGroup().apply { serialize() })
} else if (needSave()) {
val entity = SagerDatabase.groupDao.getById(DataStore.editingId)
if (entity == null) {
finish()
return
}
GroupManager.updateGroup(entity.apply { serialize() })
}
finish()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.profile_config_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_delete -> {
if (DataStore.editingId == 0L) {
finish()
} else {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_group_prompt)
.setPositiveButton(android.R.string.ok) { _, _ ->
runOnDefaultDispatcher {
GroupManager.deleteGroup(DataStore.editingId)
}
finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
true
}
R.id.action_apply -> {
runOnDefaultDispatcher {
saveAndExit()
}
true
}
else -> false
}
override fun onSupportNavigateUp(): Boolean {
if (!super.onSupportNavigateUp()) finish()
return true
}
override fun onDestroy() {
DataStore.profileCacheStore.unregisterChangeListener(this)
super.onDestroy()
}
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
if (key != Key.PROFILE_DIRTY) {
dirty = true
onBackPressedCallback.isEnabled = true
}
}
class MyPreferenceFragmentCompat : PreferenceFragmentCompat() {
val activity: GroupSettingsActivity
get() = requireActivity() as GroupSettingsActivity
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.preferenceDataStore = DataStore.profileCacheStore
try {
activity.apply {
createPreferences(savedInstanceState, rootKey)
}
} catch (e: Exception) {
Logs.w(e)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(listView) { v, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(
left = bars.left,
right = bars.right,
bottom = bars.bottom,
)
insets
}
}
}
val selectProfileForAddFront = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { (resultCode, data) ->
if (resultCode == Activity.RESULT_OK) runOnDefaultDispatcher {
val profile = ProfileManager.getProfile(
data!!.getLongExtra(ProfileSelectActivity.EXTRA_PROFILE_ID, 0)
) ?: return@runOnDefaultDispatcher
DataStore.frontProxyOutbound = profile.id
onMainDispatcher {
frontProxyPreference.value = "1"
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android16-s2-release/core/java/android/preference/ListPreference.java#167
// If the summary has a {@linkplain java.lang.String#format String formatting} marker in it,
// (i.e. "%s" or "%1$s"), then the current entry value will be substituted in its place.
frontProxyPreference.setSummary(profile.displayName().replace("%", "%%"))
}
}
}
val selectProfileForAddLanding = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { (resultCode, data) ->
if (resultCode == Activity.RESULT_OK) runOnDefaultDispatcher {
val profile = ProfileManager.getProfile(
data!!.getLongExtra(ProfileSelectActivity.EXTRA_PROFILE_ID, 0)
) ?: return@runOnDefaultDispatcher
DataStore.landingProxyOutbound = profile.id
onMainDispatcher {
landingProxyPreference.value = "1"
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android16-s2-release/core/java/android/preference/ListPreference.java#167
// If the summary has a {@linkplain java.lang.String#format String formatting} marker in it,
// (i.e. "%s" or "%1$s"), then the current entry value will be substituted in its place.
landingProxyPreference.setSummary(profile.displayName().replace("%", "%%"))
}
}
}
}