diff --git a/atox/src/main/AndroidManifest.xml b/atox/src/main/AndroidManifest.xml index 13059c6be..ae20af757 100644 --- a/atox/src/main/AndroidManifest.xml +++ b/atox/src/main/AndroidManifest.xml @@ -38,7 +38,9 @@ android:resource="@xml/file_paths" /> - + @@ -57,5 +59,8 @@ + + + diff --git a/atox/src/main/kotlin/ActivityLauncher.kt b/atox/src/main/kotlin/ActivityLauncher.kt new file mode 100644 index 000000000..fccd2e13f --- /dev/null +++ b/atox/src/main/kotlin/ActivityLauncher.kt @@ -0,0 +1,32 @@ +package ltd.evilcorp.atox + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import ltd.evilcorp.atox.settings.Settings +import javax.inject.Inject + +class ActivityLauncher : Activity() { + + @Inject + lateinit var settings: Settings + + override fun onCreate(savedInstanceState: Bundle?) { + (application as App).component.inject(this) + super.onCreate(savedInstanceState) + + val newIntent = intent.clone() as Intent + + if (settings.wipUI) { + newIntent.setClass(applicationContext, NewMainActivity::class.java) + } else { + newIntent.setClass(applicationContext, MainActivity::class.java) + } + startActivity(newIntent) + } + + override fun onStop() { + super.onStop() + finish() + } +} diff --git a/atox/src/main/kotlin/Extensions.kt b/atox/src/main/kotlin/Extensions.kt index d512dd9bf..6b2527945 100644 --- a/atox/src/main/kotlin/Extensions.kt +++ b/atox/src/main/kotlin/Extensions.kt @@ -14,7 +14,7 @@ fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED val Fragment.vmFactory: ViewModelFactory - get() = (requireActivity() as MainActivity).vmFactory + get() = (requireActivity() as? MainActivity)?.vmFactory ?: (requireActivity() as NewMainActivity).vmFactory class NoSuchArgumentException(arg: String) : Exception("No such argument: $arg") diff --git a/atox/src/main/kotlin/NewMainActivity.kt b/atox/src/main/kotlin/NewMainActivity.kt new file mode 100644 index 000000000..5f3bde900 --- /dev/null +++ b/atox/src/main/kotlin/NewMainActivity.kt @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2019-2022 aTox contributors +// +// SPDX-License-Identifier: GPL-3.0-only + +package ltd.evilcorp.atox + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.bundleOf +import androidx.core.view.WindowCompat +import androidx.navigation.fragment.findNavController +import coil.Coil +import coil.ImageLoader +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import ltd.evilcorp.atox.di.ViewModelFactory +import ltd.evilcorp.atox.settings.Settings +import ltd.evilcorp.atox.ui.contactlist.ARG_SHARE +import javax.inject.Inject + +private const val TAG = "NewMainActivity" +private const val SCHEME = "tox:" +private const val TOX_ID_LENGTH = 76 + +class NewMainActivity : AppCompatActivity() { + @Inject + lateinit var vmFactory: ViewModelFactory + + @Inject + lateinit var autoAway: AutoAway + + @Inject + lateinit var settings: Settings + + override fun onCreate(savedInstanceState: Bundle?) { + (application as App).component.inject(this) + + super.onCreate(savedInstanceState) + + if (settings.disableScreenshots) { + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + + AppCompatDelegate.setDefaultNightMode(settings.theme) + + // The view inset/padding adjustments only run for Lollipop and newer. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + WindowCompat.setDecorFitsSystemWindows(window, false) + } + + Coil.setImageLoader { + ImageLoader.Builder(this) + .componentRegistry { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder(this@NewMainActivity)) + } else { + add(GifDecoder()) + } + } + .build() + } + + setTheme(R.style.Theme_aTox_DayNight) + setContentView(R.layout.new_activity_main) + + // Only handle intent the first time it triggers the app. + if (savedInstanceState != null) return + handleIntent(intent) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleIntent(intent) + } + + override fun onPause() { + super.onPause() + autoAway.onBackground() + } + + override fun onResume() { + super.onResume() + autoAway.onForeground() + } + + private fun handleIntent(intent: Intent?) { + when (intent?.action) { + Intent.ACTION_VIEW -> handleToxLinkIntent(intent) + Intent.ACTION_SEND -> handleShareIntent(intent) + } + } + + private fun handleToxLinkIntent(intent: Intent) { + val data = intent.dataString ?: "" + Log.i(TAG, "Got uri with data: $data") + if (!data.startsWith(SCHEME) || data.length != SCHEME.length + TOX_ID_LENGTH) { + Log.e(TAG, "Got malformed uri: $data") + return + } + + supportFragmentManager.findFragmentById(R.id.nav_host_fragment)?.findNavController()?.navigate( + R.id.addContactFragment, + bundleOf("toxId" to data.drop(SCHEME.length)), + ) + } + + private fun handleShareIntent(intent: Intent) { + if (intent.type != "text/plain") { + Log.e(TAG, "Got unsupported share type ${intent.type}") + return + } + + val data = intent.getStringExtra(Intent.EXTRA_TEXT) + if (data.isNullOrEmpty()) { + Log.e(TAG, "Got share intent with no data") + return + } + + Log.i(TAG, "Got text share: $data") + val navController = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment)?.findNavController() ?: return + navController.navigate(R.id.contactListFragment, bundleOf(ARG_SHARE to data)) + } +} diff --git a/atox/src/main/kotlin/ToxService.kt b/atox/src/main/kotlin/ToxService.kt index ff178b476..7a011604e 100644 --- a/atox/src/main/kotlin/ToxService.kt +++ b/atox/src/main/kotlin/ToxService.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import ltd.evilcorp.atox.settings.Settings import ltd.evilcorp.atox.tox.ToxStarter import ltd.evilcorp.core.repository.UserRepository import ltd.evilcorp.core.vo.ConnectionStatus @@ -58,6 +59,9 @@ class ToxService : LifecycleService() { @Inject lateinit var proximityScreenOff: ProximityScreenOff + @Inject + lateinit var settings: Settings + private fun createNotificationChannel() { val channel = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW) .setName("Tox Service") @@ -74,7 +78,10 @@ class ToxService : LifecycleService() { private fun notificationFor(status: ConnectionStatus?): Notification { val pendingIntent: PendingIntent = - Intent(this, MainActivity::class.java).let { notificationIntent -> + Intent( + this, + if (settings.wipUI) NewMainActivity::class.java else MainActivity::class.java, + ).let { notificationIntent -> PendingIntentCompat.getActivity(this, 0, notificationIntent, 0) } diff --git a/atox/src/main/kotlin/di/AppComponent.kt b/atox/src/main/kotlin/di/AppComponent.kt index bd97f1364..0b23075d4 100644 --- a/atox/src/main/kotlin/di/AppComponent.kt +++ b/atox/src/main/kotlin/di/AppComponent.kt @@ -8,8 +8,10 @@ import android.content.Context import dagger.BindsInstance import dagger.Component import ltd.evilcorp.atox.ActionReceiver +import ltd.evilcorp.atox.ActivityLauncher import ltd.evilcorp.atox.BootReceiver import ltd.evilcorp.atox.MainActivity +import ltd.evilcorp.atox.NewMainActivity import ltd.evilcorp.atox.ToxService import javax.inject.Singleton @@ -29,7 +31,9 @@ interface AppComponent { fun create(@BindsInstance appContext: Context): AppComponent } + fun inject(activity: ActivityLauncher) fun inject(activity: MainActivity) + fun inject(activity: NewMainActivity) fun inject(service: ToxService) fun inject(receiver: BootReceiver) fun inject(receiver: ActionReceiver) diff --git a/atox/src/main/kotlin/di/ViewModelModule.kt b/atox/src/main/kotlin/di/ViewModelModule.kt index fc2c0fa4d..c983752b3 100644 --- a/atox/src/main/kotlin/di/ViewModelModule.kt +++ b/atox/src/main/kotlin/di/ViewModelModule.kt @@ -19,6 +19,7 @@ import ltd.evilcorp.atox.ui.friendrequest.FriendRequestViewModel import ltd.evilcorp.atox.ui.settings.SettingsViewModel import ltd.evilcorp.atox.ui.userprofile.UserProfileViewModel import kotlin.reflect.KClass +import ltd.evilcorp.atox.newui.settings.SettingsViewModel as NewSettingsViewModel @MustBeDocumented @Target( @@ -73,6 +74,11 @@ abstract class ViewModelModule { @ViewModelKey(SettingsViewModel::class) abstract fun bindSettingsViewModel(vm: SettingsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(NewSettingsViewModel::class) + abstract fun bindNewSettingsViewModel(vm: NewSettingsViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(UserProfileViewModel::class) diff --git a/atox/src/main/kotlin/newui/settings/SettingsFragment.kt b/atox/src/main/kotlin/newui/settings/SettingsFragment.kt new file mode 100644 index 000000000..6cf8e941a --- /dev/null +++ b/atox/src/main/kotlin/newui/settings/SettingsFragment.kt @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: 2019-2022 aTox contributors +// +// SPDX-License-Identifier: GPL-3.0-only + +package ltd.evilcorp.atox.newui.settings + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Spinner +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ltd.evilcorp.atox.BuildConfig +import ltd.evilcorp.atox.R +import ltd.evilcorp.atox.databinding.NewFragmentSettingsBinding +import ltd.evilcorp.atox.settings.BootstrapNodeSource +import ltd.evilcorp.atox.settings.FtAutoAccept +import ltd.evilcorp.atox.ui.BaseFragment +import ltd.evilcorp.atox.vmFactory +import ltd.evilcorp.domain.tox.ProxyType + +private fun Spinner.onItemSelectedListener(callback: (Int) -> Unit) { + this.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onNothingSelected(parent: AdapterView<*>?) {} + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + callback(position) + } + } +} + +class SettingsFragment : BaseFragment(NewFragmentSettingsBinding::inflate) { + private val vm: SettingsViewModel by viewModels { vmFactory } + private val scope = CoroutineScope(Dispatchers.Default) + private val blockBackCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + Toast.makeText(requireContext(), getString(R.string.warn_proxy_broken), Toast.LENGTH_LONG).show() + } + } + + private val applySettingsCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + vm.commit() + } + } + + private val importNodesLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + scope.launch { + if (uri != null && vm.validateNodeJson(uri)) { + if (vm.importNodeJson(uri)) { + vm.setBootstrapNodeSource(BootstrapNodeSource.UserProvided) + return@launch + } + + withContext(Dispatchers.Main) { + binding.settingBootstrapNodes.setSelection(BootstrapNodeSource.BuiltIn.ordinal) + + Toast.makeText( + requireContext(), + getString(R.string.warn_node_json_import_failed), + Toast.LENGTH_LONG, + ).show() + } + } + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + requireActivity().onBackPressedDispatcher.addCallback(this, applySettingsCallback) + requireActivity().onBackPressedDispatcher.addCallback(this, blockBackCallback) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { + ViewCompat.setOnApplyWindowInsetsListener(view) { v, compat -> + val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()) + toolbar.updatePadding(top = insets.top) + v.updatePadding(left = insets.left, right = insets.right) + version.updatePadding(bottom = insets.bottom) + compat + } + + toolbar.apply { + setNavigationIcon(R.drawable.ic_back) + setNavigationOnClickListener { + WindowInsetsControllerCompat(requireActivity().window, view) + .hide(WindowInsetsCompat.Type.ime()) + requireActivity().onBackPressed() + } + } + + theme.adapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.pref_theme_options, + android.R.layout.simple_spinner_item, + ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + + theme.setSelection(vm.getTheme()) + + theme.onItemSelectedListener { + vm.setTheme(it) + } + + settingRunAtStartup.isChecked = vm.getRunAtStartup() + settingRunAtStartup.setOnCheckedChangeListener { _, isChecked -> vm.setRunAtStartup(isChecked) } + + settingAutoAwayEnabled.isChecked = vm.getAutoAwayEnabled() + settingAutoAwayEnabled.setOnCheckedChangeListener { _, isChecked -> vm.setAutoAwayEnabled(isChecked) } + + settingAutoAwaySeconds.setText(vm.getAutoAwaySeconds().toString()) + settingAutoAwaySeconds.doAfterTextChanged { + val str = it?.toString() ?: "" + val seconds = try { + str.toLong() + } catch (e: NumberFormatException) { + settingAutoAwaySeconds.error = getString(R.string.bad_positive_number) + return@doAfterTextChanged + } + + vm.setAutoAwaySeconds(seconds) + } + + settingFtAutoAccept.adapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.pref_ft_auto_accept_options, + android.R.layout.simple_spinner_item, + ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + + settingFtAutoAccept.setSelection(vm.getFtAutoAccept().ordinal) + + settingFtAutoAccept.onItemSelectedListener { + vm.setFtAutoAccept(FtAutoAccept.values()[it]) + } + + settingConfirmQuitting.isChecked = vm.getConfirmQuitting() + settingConfirmQuitting.setOnCheckedChangeListener { _, isChecked -> vm.setConfirmQuitting(isChecked) } + + if (vm.getProxyType() != ProxyType.None) { + vm.setUdpEnabled(false) + } + + settingsUdpEnabled.isChecked = vm.getUdpEnabled() + settingsUdpEnabled.isEnabled = vm.getProxyType() == ProxyType.None + settingsUdpEnabled.setOnCheckedChangeListener { _, isChecked -> vm.setUdpEnabled(isChecked) } + + proxyType.adapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.pref_proxy_type_options, + android.R.layout.simple_spinner_item, + ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + + proxyType.setSelection(vm.getProxyType().ordinal) + + proxyType.onItemSelectedListener { + val selected = ProxyType.values()[it] + vm.setProxyType(selected) + + // Disable UDP if a proxy is selected to ensure all traffic goes through the proxy. + settingsUdpEnabled.isEnabled = selected == ProxyType.None + settingsUdpEnabled.isChecked = settingsUdpEnabled.isChecked && selected == ProxyType.None + vm.setUdpEnabled(settingsUdpEnabled.isChecked) + } + + proxyAddress.setText(vm.getProxyAddress()) + proxyAddress.doAfterTextChanged { vm.setProxyAddress(it?.toString() ?: "") } + + proxyPort.setText(vm.getProxyPort().toString()) + proxyPort.doAfterTextChanged { + val str = it?.toString() ?: "" + val port = try { + Integer.parseInt(str) + } catch (e: NumberFormatException) { + proxyPort.error = getString(R.string.bad_port) + return@doAfterTextChanged + } + + if (port < 1 || port > 65535) { + proxyPort.error = getString(R.string.bad_port) + return@doAfterTextChanged + } + + vm.setProxyPort(port) + } + + vm.proxyStatus.observe(viewLifecycleOwner) { status: ProxyStatus -> + proxyStatus.text = when (status) { + ProxyStatus.Good -> "" + ProxyStatus.BadPort -> getString(R.string.bad_port) + ProxyStatus.BadHost -> getString(R.string.bad_host) + ProxyStatus.BadType -> getString(R.string.bad_type) + ProxyStatus.NotFound -> getString(R.string.proxy_not_found) + } + blockBackCallback.isEnabled = proxyStatus.text.isNotEmpty() + } + vm.checkProxy() + + vm.committed.observe(viewLifecycleOwner) { committed -> + if (committed) { + findNavController().popBackStack() + } + } + + fun onPasswordEdit() { + passwordCurrent.error = if (vm.isCurrentPassword(passwordCurrent.text.toString())) { + null + } else { + getString(R.string.incorrect_password) + } + + passwordNewConfirm.error = if (passwordNew.text.toString() == passwordNewConfirm.text.toString()) { + null + } else { + getString(R.string.passwords_must_match) + } + + passwordConfirm.isEnabled = passwordCurrent.error == null && passwordNewConfirm.error == null + } + onPasswordEdit() + + passwordCurrent.doAfterTextChanged { onPasswordEdit() } + passwordNew.doAfterTextChanged { onPasswordEdit() } + passwordNewConfirm.doAfterTextChanged { onPasswordEdit() } + passwordConfirm.setOnClickListener { + passwordConfirm.isEnabled = false + vm.setPassword(passwordNewConfirm.text.toString()) + Toast.makeText(requireContext(), getString(R.string.password_updated), Toast.LENGTH_LONG).show() + } + + settingWipUi.isChecked = vm.getWipUI() + settingWipUi.setOnCheckedChangeListener { _, isChecked -> + if (!isChecked) { + AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.warning)) + .setMessage(getString(R.string.wip_ui_disable_notice)) + .setPositiveButton(android.R.string.ok) { _, _ -> + vm.setWipUI(false) + vm.quitTox() + activity?.finishAffinity() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.cancel() + } + .setOnCancelListener { + settingWipUi.isChecked = true + } + .show() + } + } + + if (vm.nospamAvailable()) { + @Suppress("SetTextI18n") // This should be displayed the way Tox likes it. + nospam.setText("%08X".format(vm.getNospam())) + nospam.doAfterTextChanged { + saveNospam.isEnabled = + nospam.text.length == 8 && nospam.text.toString().toUInt(16).toInt() != vm.getNospam() + } + saveNospam.isEnabled = false + saveNospam.setOnClickListener { + vm.setNospam(nospam.text.toString().toUInt(16).toInt()) + saveNospam.isEnabled = false + Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_LONG).show() + } + } else { + nospam.isEnabled = false + saveNospam.isEnabled = false + nospamExtraText.text = getString(R.string.pref_disabled_tox_error) + } + + settingBootstrapNodes.adapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.pref_bootstrap_node_options, + android.R.layout.simple_spinner_item, + ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + + settingBootstrapNodes.setSelection(vm.getBootstrapNodeSource().ordinal) + + settingBootstrapNodes.onItemSelectedListener { + val source = BootstrapNodeSource.values()[it] + + // Hack to avoid triggering the document chooser again if the user has set it to UserProvided. + if (source == vm.getBootstrapNodeSource()) return@onItemSelectedListener + + if (source == BootstrapNodeSource.BuiltIn) { + vm.setBootstrapNodeSource(source) + } else { + importNodesLauncher.launch(arrayOf("application/json")) + } + } + + settingDisableScreenshots.isChecked = vm.getDisableScreenshots() + settingDisableScreenshots.setOnCheckedChangeListener { _, isChecked -> + vm.setDisableScreenshots(isChecked) + if (isChecked) { + requireActivity().window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } else { + requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + version.text = getString(R.string.version_display, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) + } +} diff --git a/atox/src/main/kotlin/newui/settings/SettingsViewModel.kt b/atox/src/main/kotlin/newui/settings/SettingsViewModel.kt new file mode 100644 index 000000000..83e7ec6c4 --- /dev/null +++ b/atox/src/main/kotlin/newui/settings/SettingsViewModel.kt @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2019-2022 aTox contributors +// +// SPDX-License-Identifier: GPL-3.0-only + +package ltd.evilcorp.atox.newui.settings + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ltd.evilcorp.atox.settings.BootstrapNodeSource +import ltd.evilcorp.atox.settings.FtAutoAccept +import ltd.evilcorp.atox.settings.Settings +import ltd.evilcorp.atox.tox.ToxStarter +import ltd.evilcorp.domain.tox.BootstrapNodeJsonParser +import ltd.evilcorp.domain.tox.BootstrapNodeRegistry +import ltd.evilcorp.domain.tox.ProxyType +import ltd.evilcorp.domain.tox.SaveOptions +import ltd.evilcorp.domain.tox.Tox +import ltd.evilcorp.domain.tox.ToxSaveStatus +import ltd.evilcorp.domain.tox.testToxSave +import java.io.File +import javax.inject.Inject +import kotlin.math.max + +private const val TOX_SHUTDOWN_POLL_DELAY_MS = 200L + +enum class ProxyStatus { + Good, + BadPort, + BadHost, + BadType, + NotFound, +} + +class SettingsViewModel @Inject constructor( + private val context: Context, + private val resolver: ContentResolver, + private val settings: Settings, + private val toxStarter: ToxStarter, + private val tox: Tox, + private val nodeParser: BootstrapNodeJsonParser, + private val nodeRegistry: BootstrapNodeRegistry, +) : ViewModel() { + private var restartNeeded = false + + private val _proxyStatus = MutableLiveData() + val proxyStatus: LiveData get() = _proxyStatus + + private val _committed = MutableLiveData().apply { value = false } + val committed: LiveData get() = _committed + + fun nospamAvailable(): Boolean = tox.started + fun getNospam(): Int = tox.nospam + fun setNospam(value: Int) { + tox.nospam = value + } + + // The trickery here is because the values in the dropdown are 0, 1, 2 for auto, no, yes; + // while in Android, the values are -1, 1, 2 for auto, no, yes; so we map -1 to 0 when getting, + // and 0 to -1 when setting. + fun getTheme(): Int = max(0, settings.theme) + fun setTheme(theme: Int) { + settings.theme = when (theme) { + 0 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + 1 -> AppCompatDelegate.MODE_NIGHT_NO + 2 -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + } + + fun getFtAutoAccept(): FtAutoAccept = settings.ftAutoAccept + fun setFtAutoAccept(autoAccept: FtAutoAccept) { + settings.ftAutoAccept = autoAccept + } + + fun getUdpEnabled(): Boolean = settings.udpEnabled + fun setUdpEnabled(enabled: Boolean) { + if (enabled == getUdpEnabled()) return + settings.udpEnabled = enabled + restartNeeded = true + } + + fun getRunAtStartup(): Boolean = settings.runAtStartup + fun setRunAtStartup(enabled: Boolean) { + settings.runAtStartup = enabled + } + + fun getAutoAwayEnabled() = settings.autoAwayEnabled + fun setAutoAwayEnabled(enabled: Boolean) { + settings.autoAwayEnabled = enabled + } + + fun getConfirmQuitting(): Boolean = settings.confirmQuitting + fun setConfirmQuitting(enabled: Boolean) { + settings.confirmQuitting = enabled + } + + fun getAutoAwaySeconds() = settings.autoAwaySeconds + fun setAutoAwaySeconds(seconds: Long) { + settings.autoAwaySeconds = seconds + } + + fun commit() { + if (!restartNeeded) { + _committed.value = true + return + } + + val password = tox.password + toxStarter.stopTox() + + viewModelScope.launch { + while (tox.started) { + delay(TOX_SHUTDOWN_POLL_DELAY_MS) + } + toxStarter.tryLoadTox(password) + _committed.value = true + } + } + + private var checkProxyJob: Job? = null + fun checkProxy() { + checkProxyJob?.cancel(null) + checkProxyJob = viewModelScope.launch(Dispatchers.IO) { + val saveStatus = testToxSave( + SaveOptions(saveData = null, getUdpEnabled(), getProxyType(), getProxyAddress(), getProxyPort()), + null, + ) + + val proxyStatus = when (saveStatus) { + ToxSaveStatus.BadProxyHost -> ProxyStatus.BadHost + ToxSaveStatus.BadProxyPort -> ProxyStatus.BadPort + ToxSaveStatus.BadProxyType -> ProxyStatus.BadType + ToxSaveStatus.ProxyNotFound -> ProxyStatus.NotFound + else -> ProxyStatus.Good + } + + _proxyStatus.postValue(proxyStatus) + } + } + + fun getProxyType(): ProxyType = settings.proxyType + fun setProxyType(type: ProxyType) { + if (type != getProxyType()) { + settings.proxyType = type + restartNeeded = true + checkProxy() + } + } + + fun getProxyAddress(): String = settings.proxyAddress + fun setProxyAddress(address: String) { + if (address != getProxyAddress()) { + settings.proxyAddress = address + if (getProxyType() != ProxyType.None) { + restartNeeded = true + } + checkProxy() + } + } + + fun getProxyPort(): Int = settings.proxyPort + fun setProxyPort(port: Int) { + if (port != getProxyPort()) { + settings.proxyPort = port + if (getProxyType() != ProxyType.None) { + restartNeeded = true + } + checkProxy() + } + } + + fun isCurrentPassword(maybeCurrentPassword: String) = + tox.password == maybeCurrentPassword.ifEmpty { null } + + fun setPassword(newPassword: String) = + tox.changePassword(newPassword.ifEmpty { null }) + + fun getBootstrapNodeSource(): BootstrapNodeSource = settings.bootstrapNodeSource + fun setBootstrapNodeSource(source: BootstrapNodeSource) { + settings.bootstrapNodeSource = source + nodeRegistry.reset() + restartNeeded = true + } + + suspend fun validateNodeJson(uri: Uri): Boolean = withContext(Dispatchers.IO) { + val bytes = resolver.openInputStream(uri)?.use { + it.readBytes() + } ?: return@withContext false + + return@withContext nodeParser.parse(bytes.decodeToString()).isNotEmpty() + } + + suspend fun importNodeJson(uri: Uri): Boolean = withContext(Dispatchers.IO) { + val bytes = resolver.openInputStream(uri)?.use { + it.readBytes() + } ?: return@withContext false + + val out = File(context.filesDir, "user_nodes.json") + out.delete() + if (!out.createNewFile()) return@withContext false + + out.outputStream().use { it.write(bytes) } + return@withContext true + } + + fun getDisableScreenshots(): Boolean = settings.disableScreenshots + fun setDisableScreenshots(disable: Boolean) { + settings.disableScreenshots = disable + } + + fun quitTox() = toxStarter.stopTox() + + fun getWipUI(): Boolean = settings.wipUI + fun setWipUI(wipUI: Boolean) { + settings.wipUI = wipUI + } +} diff --git a/atox/src/main/kotlin/settings/Settings.kt b/atox/src/main/kotlin/settings/Settings.kt index 50e68961f..5a2318765 100644 --- a/atox/src/main/kotlin/settings/Settings.kt +++ b/atox/src/main/kotlin/settings/Settings.kt @@ -92,4 +92,8 @@ class Settings @Inject constructor(private val ctx: Context) { var confirmQuitting: Boolean get() = preferences.getBoolean("confirm_quitting", true) set(confirm) = preferences.edit { putBoolean("confirm_quitting", confirm) } + + var wipUI: Boolean + get() = preferences.getBoolean("wip_ui", false) + set(isEnabled) = preferences.edit { putBoolean("wip_ui", isEnabled) } } diff --git a/atox/src/main/kotlin/ui/settings/SettingsFragment.kt b/atox/src/main/kotlin/ui/settings/SettingsFragment.kt index 32f086723..0f8fe1b89 100644 --- a/atox/src/main/kotlin/ui/settings/SettingsFragment.kt +++ b/atox/src/main/kotlin/ui/settings/SettingsFragment.kt @@ -4,13 +4,18 @@ package ltd.evilcorp.atox.ui.settings +import android.app.AlertDialog import android.content.Context import android.os.Bundle +import android.text.SpannableString +import android.text.method.LinkMovementMethod +import android.text.util.Linkify import android.view.View import android.view.WindowManager import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Spinner +import android.widget.TextView import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts @@ -241,6 +246,38 @@ class SettingsFragment : BaseFragment(FragmentSettingsB Toast.makeText(requireContext(), getString(R.string.password_updated), Toast.LENGTH_LONG).show() } + settingWipUi.isChecked = vm.getWipUI() + settingWipUi.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + val message = SpannableString( + getString( + R.string.wip_ui_enable_warning, + "https://github.com/evilcorpltd/aTox/issues/1042", + ), + ) + Linkify.addLinks(message, Linkify.WEB_URLS) + + val dialog = AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.warning)) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> + vm.setWipUI(true) + vm.quitTox() + activity?.finishAffinity() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.cancel() + } + .setOnCancelListener { + settingWipUi.isChecked = false + } + .create() + + dialog.show() + dialog.findViewById(android.R.id.message).movementMethod = LinkMovementMethod.getInstance() + } + } + if (vm.nospamAvailable()) { @Suppress("SetTextI18n") // This should be displayed the way Tox likes it. nospam.setText("%08X".format(vm.getNospam())) diff --git a/atox/src/main/kotlin/ui/settings/SettingsViewModel.kt b/atox/src/main/kotlin/ui/settings/SettingsViewModel.kt index f7d13355e..db828b565 100644 --- a/atox/src/main/kotlin/ui/settings/SettingsViewModel.kt +++ b/atox/src/main/kotlin/ui/settings/SettingsViewModel.kt @@ -218,4 +218,11 @@ class SettingsViewModel @Inject constructor( fun setDisableScreenshots(disable: Boolean) { settings.disableScreenshots = disable } + + fun quitTox() = toxStarter.stopTox() + + fun getWipUI(): Boolean = settings.wipUI + fun setWipUI(wipUI: Boolean) { + settings.wipUI = wipUI + } } diff --git a/atox/src/main/res/layout/fragment_settings.xml b/atox/src/main/res/layout/fragment_settings.xml index b03c63ab3..c0c97a463 100644 --- a/atox/src/main/res/layout/fragment_settings.xml +++ b/atox/src/main/res/layout/fragment_settings.xml @@ -437,6 +437,45 @@ + + + + + + + + + + + + + + diff --git a/atox/src/main/res/layout/new_fragment_settings.xml b/atox/src/main/res/layout/new_fragment_settings.xml new file mode 100644 index 000000000..c0c97a463 --- /dev/null +++ b/atox/src/main/res/layout/new_fragment_settings.xml @@ -0,0 +1,616 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +