Init commit
This commit is contained in:
331
app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt
Normal file
331
app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt
Normal file
@@ -0,0 +1,331 @@
|
||||
package io.nekohasekai.sfa.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.microsoft.appcenter.AppCenter
|
||||
import com.microsoft.appcenter.analytics.Analytics
|
||||
import com.microsoft.appcenter.crashes.Crashes
|
||||
import com.microsoft.appcenter.distribute.Distribute
|
||||
import com.microsoft.appcenter.distribute.DistributeListener
|
||||
import com.microsoft.appcenter.distribute.ReleaseDetails
|
||||
import com.microsoft.appcenter.distribute.UpdateAction
|
||||
import com.microsoft.appcenter.utils.AppNameHelper
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||
import io.nekohasekai.sfa.bg.ServiceNotification
|
||||
import io.nekohasekai.sfa.constant.Alert
|
||||
import io.nekohasekai.sfa.constant.ServiceMode
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.databinding.ActivityMainBinding
|
||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||
import io.nekohasekai.sfa.ui.shared.AbstractActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.LinkedList
|
||||
|
||||
class MainActivity : AbstractActivity(), ServiceConnection.Callback, DistributeListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MyActivity"
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val connection = ServiceConnection(this, this)
|
||||
|
||||
val logList = LinkedList<String>()
|
||||
var logCallback: ((Boolean) -> Unit)? = null
|
||||
val serviceStatus = MutableLiveData(Status.Stopped)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val navController = findNavController(R.id.nav_host_fragment_activity_my)
|
||||
val appBarConfiguration =
|
||||
AppBarConfiguration(
|
||||
setOf(
|
||||
R.id.navigation_dashboard,
|
||||
R.id.navigation_log,
|
||||
R.id.navigation_configuration,
|
||||
R.id.navigation_settings,
|
||||
)
|
||||
)
|
||||
setupActionBarWithNavController(navController, appBarConfiguration)
|
||||
binding.navView.setupWithNavController(navController)
|
||||
|
||||
reconnect()
|
||||
startAnalysis()
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
connection.reconnect()
|
||||
}
|
||||
|
||||
private fun startAnalysis() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
when (Settings.analyticsAllowed) {
|
||||
Settings.ANALYSIS_UNKNOWN -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
showAnalysisDialog()
|
||||
}
|
||||
}
|
||||
|
||||
Settings.ANALYSIS_ALLOWED -> {
|
||||
startAnalysisInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAnalysisDialog() {
|
||||
val builder = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.analytics_title))
|
||||
.setMessage(getString(R.string.analytics_message))
|
||||
.setPositiveButton(getString(R.string.ok)) { _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Settings.analyticsAllowed = Settings.ANALYSIS_ALLOWED
|
||||
startAnalysisInternal()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(getString(R.string.no_thanks)) { _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Settings.analyticsAllowed = Settings.ANALYSIS_DISALLOWED
|
||||
}
|
||||
}
|
||||
runCatching { builder.show() }
|
||||
}
|
||||
|
||||
suspend fun startAnalysisInternal() {
|
||||
if (BuildConfig.APPCENTER_SECRET.isBlank()) {
|
||||
return
|
||||
}
|
||||
Distribute.setListener(this)
|
||||
runCatching {
|
||||
AppCenter.start(
|
||||
application,
|
||||
BuildConfig.APPCENTER_SECRET,
|
||||
Analytics::class.java,
|
||||
Crashes::class.java,
|
||||
Distribute::class.java,
|
||||
)
|
||||
if (!Settings.checkUpdateEnabled) {
|
||||
Distribute.disableAutomaticCheckForUpdate()
|
||||
}
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReleaseAvailable(activity: Activity, releaseDetails: ReleaseDetails): Boolean {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
delay(2000L)
|
||||
runCatching {
|
||||
onReleaseAvailable0(releaseDetails)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onReleaseAvailable0(releaseDetails: ReleaseDetails) {
|
||||
val builder = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_title))
|
||||
var message = if (releaseDetails.isMandatoryUpdate) {
|
||||
getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_message_mandatory)
|
||||
} else {
|
||||
getString(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_message_optional)
|
||||
}
|
||||
message = String.format(
|
||||
message,
|
||||
AppNameHelper.getAppName(this),
|
||||
releaseDetails.shortVersion,
|
||||
releaseDetails.version
|
||||
)
|
||||
builder.setMessage(message)
|
||||
builder.setPositiveButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_download) { _, _ ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, releaseDetails.downloadUrl))
|
||||
}
|
||||
builder.setCancelable(false)
|
||||
if (!releaseDetails.isMandatoryUpdate) {
|
||||
builder.setNegativeButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_postpone) { _, _ ->
|
||||
Distribute.notifyUpdateAction(UpdateAction.POSTPONE)
|
||||
}
|
||||
}
|
||||
if (!TextUtils.isEmpty(releaseDetails.releaseNotes) && releaseDetails.releaseNotesUrl != null) {
|
||||
builder.setNeutralButton(com.microsoft.appcenter.distribute.R.string.appcenter_distribute_update_dialog_view_release_notes) { _, _ ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, releaseDetails.releaseNotesUrl))
|
||||
}
|
||||
}
|
||||
builder.show()
|
||||
}
|
||||
|
||||
override fun onNoReleaseAvailable(activity: Activity) {
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
fun startService() {
|
||||
if (!ServiceNotification.checkPermission()) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
return
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
if (Settings.rebuildServiceMode()) {
|
||||
reconnect()
|
||||
}
|
||||
if (Settings.serviceMode == ServiceMode.VPN) {
|
||||
if (prepare()) {
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
val intent = Intent(Application.application, Settings.serviceClass())
|
||||
withContext(Dispatchers.Main) {
|
||||
ContextCompat.startForegroundService(Application.application, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) {
|
||||
if (it) {
|
||||
startService()
|
||||
} else {
|
||||
onServiceAlert(Alert.RequestNotificationPermission, null)
|
||||
}
|
||||
}
|
||||
|
||||
private val prepareLauncher = registerForActivityResult(PrepareService()) {
|
||||
if (it) {
|
||||
startService()
|
||||
} else {
|
||||
onServiceAlert(Alert.RequestVPNPermission, null)
|
||||
}
|
||||
}
|
||||
|
||||
private class PrepareService : ActivityResultContract<Intent, Boolean>() {
|
||||
override fun createIntent(context: Context, input: Intent): Intent {
|
||||
return input
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == RESULT_OK
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prepare() = withContext(Dispatchers.Main) {
|
||||
try {
|
||||
val intent = VpnService.prepare(this@MainActivity)
|
||||
if (intent != null) {
|
||||
prepareLauncher.launch(intent)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onServiceAlert(Alert.RequestVPNPermission, e.message)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceStatusChanged(status: Status) {
|
||||
serviceStatus.postValue(status)
|
||||
}
|
||||
|
||||
override fun onServiceAlert(type: Alert, message: String?) {
|
||||
val builder = MaterialAlertDialogBuilder(this)
|
||||
builder.setPositiveButton(resources.getString(android.R.string.ok), null)
|
||||
when (type) {
|
||||
Alert.RequestVPNPermission -> {
|
||||
builder.setMessage(getString(R.string.service_error_missing_permission))
|
||||
}
|
||||
|
||||
Alert.RequestNotificationPermission -> {
|
||||
builder.setMessage(getString(R.string.service_error_missing_notification_permission))
|
||||
}
|
||||
|
||||
Alert.EmptyConfiguration -> {
|
||||
builder.setMessage(getString(R.string.service_error_empty_configuration))
|
||||
}
|
||||
|
||||
Alert.StartCommandServer -> {
|
||||
builder.setTitle(getString(R.string.service_error_title_start_command_server))
|
||||
builder.setMessage(message)
|
||||
}
|
||||
|
||||
Alert.CreateService -> {
|
||||
builder.setTitle(getString(R.string.service_error_title_create_service))
|
||||
builder.setMessage(message)
|
||||
}
|
||||
|
||||
Alert.StartService -> {
|
||||
builder.setTitle(getString(R.string.service_error_title_start_service))
|
||||
builder.setMessage(message)
|
||||
|
||||
}
|
||||
}
|
||||
builder.show()
|
||||
}
|
||||
|
||||
private var paused = false
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
paused = true
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
paused = false
|
||||
logCallback?.invoke(true)
|
||||
}
|
||||
|
||||
override fun onServiceWriteLog(message: String?) {
|
||||
if (paused) {
|
||||
if (logList.size > 300) {
|
||||
logList.removeFirst()
|
||||
}
|
||||
}
|
||||
logList.addLast(message)
|
||||
if (!paused) {
|
||||
logCallback?.invoke(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceResetLogs(messages: MutableList<String>) {
|
||||
logList.clear()
|
||||
logList.addAll(messages)
|
||||
if (!paused) logCallback?.invoke(true)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
connection.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
}
|
||||
67
app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt
Normal file
67
app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
package io.nekohasekai.sfa.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.BoxService
|
||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
|
||||
class ShortcutActivity : Activity(), ServiceConnection.Callback {
|
||||
|
||||
private val connection = ServiceConnection(this, this, false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (intent.action == Intent.ACTION_CREATE_SHORTCUT) {
|
||||
setResult(
|
||||
RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(
|
||||
this,
|
||||
ShortcutInfoCompat.Builder(this, "toggle")
|
||||
.setIntent(
|
||||
Intent(
|
||||
this,
|
||||
ShortcutActivity::class.java
|
||||
).setAction(Intent.ACTION_MAIN)
|
||||
)
|
||||
.setIcon(
|
||||
IconCompat.createWithResource(
|
||||
this,
|
||||
R.mipmap.ic_launcher
|
||||
)
|
||||
)
|
||||
.setShortLabel(getString(R.string.quick_toggle))
|
||||
.build()
|
||||
)
|
||||
)
|
||||
finish()
|
||||
} else {
|
||||
connection.connect()
|
||||
if (Build.VERSION.SDK_INT >= 25) {
|
||||
getSystemService<ShortcutManager>()?.reportShortcutUsed("toggle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceStatusChanged(status: Status) {
|
||||
when (status) {
|
||||
Status.Started -> BoxService.stop()
|
||||
Status.Stopped -> BoxService.start()
|
||||
else -> {}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
connection.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package io.nekohasekai.sfa.ui.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
import io.nekohasekai.sfa.database.Profiles
|
||||
import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding
|
||||
import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding
|
||||
import io.nekohasekai.sfa.ui.profile.EditProfileActivity
|
||||
import io.nekohasekai.sfa.ui.profile.NewProfileActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ConfigurationFragment : Fragment() {
|
||||
|
||||
private var _adapter: Adapter? = null
|
||||
private var adapter: Adapter
|
||||
get() = _adapter as Adapter
|
||||
set(value) {
|
||||
_adapter = value
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
val binding = FragmentConfigurationBinding.inflate(inflater, container, false)
|
||||
adapter = Adapter(lifecycleScope, binding)
|
||||
binding.profileList.also {
|
||||
it.layoutManager = LinearLayoutManager(requireContext())
|
||||
it.adapter = adapter
|
||||
ItemTouchHelper(object :
|
||||
ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
return adapter.move(viewHolder.adapterPosition, target.adapterPosition)
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
}
|
||||
}).attachToRecyclerView(it)
|
||||
}
|
||||
adapter.reload()
|
||||
binding.fab.setOnClickListener {
|
||||
startActivity(Intent(requireContext(), NewProfileActivity::class.java))
|
||||
}
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
_adapter?.reload()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_adapter = null
|
||||
}
|
||||
|
||||
class Adapter(
|
||||
internal val scope: CoroutineScope,
|
||||
private val parent: FragmentConfigurationBinding
|
||||
) :
|
||||
RecyclerView.Adapter<Holder>() {
|
||||
|
||||
internal var items: MutableList<Profile> = mutableListOf()
|
||||
|
||||
internal fun reload() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
items = Profiles.list().toMutableList()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (items.isEmpty()) {
|
||||
parent.statusText.isVisible = true
|
||||
parent.profileList.isVisible = false
|
||||
} else if (parent.statusText.isVisible) {
|
||||
parent.statusText.isVisible = false
|
||||
parent.profileList.isVisible = true
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun move(from: Int, to: Int): Boolean {
|
||||
val first = items.getOrNull(from) ?: return false
|
||||
var previousOrder = first.userOrder
|
||||
val (step, range) = if (from < to) Pair(1, from until to) else Pair(
|
||||
-1, to + 1 downTo from
|
||||
)
|
||||
val updated = mutableListOf<Profile>()
|
||||
for (i in range) {
|
||||
val next = items.getOrNull(i + step) ?: return false
|
||||
val order = next.userOrder
|
||||
next.userOrder = previousOrder
|
||||
previousOrder = order
|
||||
updated.add(next)
|
||||
}
|
||||
first.userOrder = previousOrder
|
||||
updated.add(first)
|
||||
notifyItemMoved(from, to)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Profiles.update(updated)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
this,
|
||||
ViewConfigutationItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return items.size
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Holder(private val adapter: Adapter, private val binding: ViewConfigutationItemBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
internal fun bind(profile: Profile) {
|
||||
binding.profileName.text = profile.name
|
||||
binding.root.setOnClickListener {
|
||||
val intent = Intent(binding.root.context, EditProfileActivity::class.java)
|
||||
intent.putExtra("profile_id", profile.id)
|
||||
it.context.startActivity(intent)
|
||||
}
|
||||
binding.moreButton.setOnClickListener { it ->
|
||||
val popup = PopupMenu(it.context, it)
|
||||
popup.setForceShowIcon(true)
|
||||
popup.menuInflater.inflate(R.menu.profile_menu, popup.menu)
|
||||
popup.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_delete -> {
|
||||
adapter.items.remove(profile)
|
||||
adapter.notifyItemRemoved(adapterPosition)
|
||||
adapter.scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Profiles.delete(profile)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package io.nekohasekai.sfa.ui.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||
import go.Seq
|
||||
import io.nekohasekai.libbox.CommandClient
|
||||
import io.nekohasekai.libbox.CommandClientHandler
|
||||
import io.nekohasekai.libbox.CommandClientOptions
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.StatusMessage
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.BoxService
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
import io.nekohasekai.sfa.database.Profiles
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.databinding.FragmentDashboardBinding
|
||||
import io.nekohasekai.sfa.databinding.ViewProfileItemBinding
|
||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||
import io.nekohasekai.sfa.ui.MainActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DashboardFragment : Fragment(), CommandClientHandler {
|
||||
|
||||
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
||||
private var _binding: FragmentDashboardBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var commandClient: CommandClient? = null
|
||||
|
||||
private var _adapter: Adapter? = null
|
||||
private val adapter get() = _adapter!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||
onCreate()
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun onCreate() {
|
||||
val activity = activity ?: return
|
||||
|
||||
binding.profileList.adapter = Adapter(lifecycleScope, binding).apply {
|
||||
_adapter = this
|
||||
reload()
|
||||
}
|
||||
binding.profileList.layoutManager = LinearLayoutManager(requireContext())
|
||||
val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
|
||||
divider.isLastItemDecorated = false
|
||||
binding.profileList.addItemDecoration(divider)
|
||||
|
||||
activity.serviceStatus.observe(viewLifecycleOwner) {
|
||||
binding.statusCard.isVisible = it == Status.Starting || it == Status.Started
|
||||
when (it) {
|
||||
Status.Stopped -> {
|
||||
binding.fab.setImageResource(R.drawable.ic_play_arrow_24)
|
||||
binding.fab.show()
|
||||
}
|
||||
|
||||
Status.Starting -> {
|
||||
binding.fab.hide()
|
||||
}
|
||||
|
||||
Status.Started -> {
|
||||
binding.fab.setImageResource(R.drawable.ic_stop_24)
|
||||
binding.fab.show()
|
||||
reconnect()
|
||||
}
|
||||
|
||||
Status.Stopping -> {
|
||||
binding.fab.hide()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
binding.fab.setOnClickListener {
|
||||
when (activity.serviceStatus.value) {
|
||||
Status.Stopped -> {
|
||||
activity.startService()
|
||||
}
|
||||
|
||||
Status.Started -> {
|
||||
BoxService.stop()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnect() {
|
||||
disconnect()
|
||||
val options = CommandClientOptions()
|
||||
options.command = Libbox.CommandStatus
|
||||
options.statusInterval = 2 * 1000 * 1000 * 1000
|
||||
val commandClient = CommandClient(requireContext().filesDir.absolutePath, this, options)
|
||||
this.commandClient = commandClient
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
for (i in 1..3) {
|
||||
delay(100)
|
||||
try {
|
||||
commandClient.connect()
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
commandClient?.apply {
|
||||
runCatching {
|
||||
disconnect()
|
||||
}
|
||||
Seq.destroyRef(refnum)
|
||||
}
|
||||
commandClient = null
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_adapter = null
|
||||
_binding = null
|
||||
disconnect()
|
||||
}
|
||||
|
||||
override fun connected() {
|
||||
val binding = _binding ?: return
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
binding.memoryText.text = getString(R.string.loading)
|
||||
binding.goroutinesText.text = getString(R.string.loading)
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnected(message: String?) {
|
||||
val binding = _binding ?: return
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
binding.memoryText.text = getString(R.string.loading)
|
||||
binding.goroutinesText.text = getString(R.string.loading)
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeLog(message: String) {
|
||||
}
|
||||
|
||||
override fun writeStatus(message: StatusMessage) {
|
||||
val binding = _binding ?: return
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
binding.memoryText.text = Libbox.formatBytes(message.memory)
|
||||
binding.goroutinesText.text = message.goroutines.toString()
|
||||
}
|
||||
}
|
||||
|
||||
class Adapter(
|
||||
internal val scope: CoroutineScope,
|
||||
private val parent: FragmentDashboardBinding
|
||||
) :
|
||||
RecyclerView.Adapter<Holder>() {
|
||||
|
||||
internal var items: MutableList<Profile> = mutableListOf()
|
||||
internal var selectedProfileID = -1L
|
||||
internal var lastSelectedIndex: Int? = null
|
||||
internal fun reload() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
items = Profiles.list().toMutableList()
|
||||
if (items.isNotEmpty()) {
|
||||
selectedProfileID = Settings.selectedProfile
|
||||
for ((index, profile) in items.withIndex()) {
|
||||
if (profile.id == selectedProfileID) {
|
||||
lastSelectedIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastSelectedIndex == null) {
|
||||
lastSelectedIndex = 0
|
||||
selectedProfileID = items[0].id
|
||||
Settings.selectedProfile = selectedProfileID
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
parent.statusText.isVisible = items.isEmpty()
|
||||
parent.container.isVisible = items.isNotEmpty()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
this,
|
||||
ViewProfileItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return items.size
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Holder(
|
||||
private val adapter: Adapter,
|
||||
private val binding: ViewProfileItemBinding
|
||||
) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
internal fun bind(profile: Profile) {
|
||||
binding.profileName.text = profile.name
|
||||
binding.profileSelected.setOnCheckedChangeListener(null)
|
||||
binding.profileSelected.isChecked = profile.id == adapter.selectedProfileID
|
||||
binding.profileSelected.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked) {
|
||||
adapter.selectedProfileID = profile.id
|
||||
adapter.lastSelectedIndex?.let { index ->
|
||||
adapter.notifyItemChanged(index)
|
||||
}
|
||||
adapter.lastSelectedIndex = adapterPosition
|
||||
adapter.scope.launch(Dispatchers.IO) {
|
||||
switchProfile(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.root.setOnClickListener {
|
||||
binding.profileSelected.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun switchProfile(profile: Profile) {
|
||||
Settings.selectedProfile = profile.id
|
||||
val mainActivity = (binding.root.context as? MainActivity) ?: return
|
||||
val started = mainActivity.serviceStatus.value == Status.Started
|
||||
if (!started) {
|
||||
return
|
||||
}
|
||||
val restart = Settings.rebuildServiceMode()
|
||||
if (restart) {
|
||||
mainActivity.reconnect()
|
||||
BoxService.stop()
|
||||
delay(200)
|
||||
mainActivity.startService()
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
Libbox.clientServiceReload(mainActivity.filesDir.absolutePath)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
mainActivity.errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
147
app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt
Normal file
147
app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package io.nekohasekai.sfa.ui.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.BoxService
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.databinding.FragmentLogBinding
|
||||
import io.nekohasekai.sfa.databinding.ViewLogTextItemBinding
|
||||
import io.nekohasekai.sfa.ui.MainActivity
|
||||
import io.nekohasekai.sfa.utils.ColorUtils
|
||||
import java.util.LinkedList
|
||||
|
||||
class LogFragment : Fragment() {
|
||||
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
||||
private var _binding: FragmentLogBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var logAdapter: LogAdapter? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentLogBinding.inflate(inflater, container, false)
|
||||
onCreate()
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun onCreate() {
|
||||
val activity = activity ?: return
|
||||
activity.logCallback = ::updateViews
|
||||
binding.logView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.logView.adapter = LogAdapter(activity.logList).also { logAdapter = it }
|
||||
updateViews(true)
|
||||
activity.serviceStatus.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
Status.Stopped -> {
|
||||
binding.fab.setImageResource(R.drawable.ic_play_arrow_24)
|
||||
binding.fab.show()
|
||||
binding.statusText.setText(R.string.status_default)
|
||||
}
|
||||
|
||||
Status.Starting -> {
|
||||
binding.fab.hide()
|
||||
binding.statusText.setText(R.string.status_starting)
|
||||
}
|
||||
|
||||
Status.Started -> {
|
||||
binding.fab.setImageResource(R.drawable.ic_stop_24)
|
||||
binding.fab.show()
|
||||
binding.statusText.setText(R.string.status_started)
|
||||
}
|
||||
|
||||
Status.Stopping -> {
|
||||
binding.fab.hide()
|
||||
binding.statusText.setText(R.string.status_stopping)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
binding.fab.setOnClickListener {
|
||||
when (activity.serviceStatus.value) {
|
||||
Status.Stopped -> {
|
||||
activity.startService()
|
||||
}
|
||||
|
||||
Status.Started -> {
|
||||
BoxService.stop()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateViews(reset: Boolean) {
|
||||
val activity = activity ?: return
|
||||
val logAdapter = logAdapter ?: return
|
||||
if (activity.logList.isEmpty()) {
|
||||
binding.logView.isVisible = false
|
||||
binding.statusText.isVisible = true
|
||||
} else if (!binding.logView.isVisible) {
|
||||
binding.logView.isVisible = true
|
||||
binding.statusText.isVisible = false
|
||||
}
|
||||
if (reset) {
|
||||
logAdapter.notifyDataSetChanged()
|
||||
binding.logView.scrollToPosition(activity.logList.size - 1)
|
||||
} else {
|
||||
binding.logView.scrollToPosition(logAdapter.notifyItemInserted())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
activity?.logCallback = null
|
||||
logAdapter = null
|
||||
}
|
||||
|
||||
|
||||
class LogAdapter(private val logList: LinkedList<String>) :
|
||||
RecyclerView.Adapter<LogViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
|
||||
return LogViewHolder(
|
||||
ViewLogTextItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
|
||||
holder.bind(logList.getOrElse(position) { "" })
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return logList.size
|
||||
}
|
||||
|
||||
fun notifyItemInserted(): Int {
|
||||
if (logList.size > 300) {
|
||||
logList.removeFirst()
|
||||
notifyItemRemoved(0)
|
||||
}
|
||||
|
||||
val position = logList.size - 1
|
||||
notifyItemInserted(position)
|
||||
return position
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class LogViewHolder(private val binding: ViewLogTextItemBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(message: String) {
|
||||
binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
108
app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt
Normal file
108
app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package io.nekohasekai.sfa.ui.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.microsoft.appcenter.AppCenter
|
||||
import com.microsoft.appcenter.distribute.Distribute
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.constant.EnabledType
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.databinding.FragmentSettingsBinding
|
||||
import io.nekohasekai.sfa.ktx.addTextChangedListener
|
||||
import io.nekohasekai.sfa.ktx.launchCustomTab
|
||||
import io.nekohasekai.sfa.ktx.setSimpleItems
|
||||
import io.nekohasekai.sfa.ktx.text
|
||||
import io.nekohasekai.sfa.ui.MainActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
|
||||
onCreate()
|
||||
return binding.root
|
||||
}
|
||||
|
||||
private fun onCreate() {
|
||||
val activity = activity as MainActivity? ?: return
|
||||
binding.versionText.text = Libbox.version()
|
||||
binding.clearButton.setOnClickListener {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
activity.getExternalFilesDir(null)?.deleteRecursively()
|
||||
reloadSettings()
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
reloadSettings()
|
||||
}
|
||||
binding.appCenterEnabled.addTextChangedListener {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val allowed = EnabledType.valueOf(it).boolValue
|
||||
Settings.analyticsAllowed =
|
||||
if (allowed) Settings.ANALYSIS_ALLOWED else Settings.ANALYSIS_DISALLOWED
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.checkUpdateEnabled.isEnabled = allowed
|
||||
}
|
||||
if (!allowed) {
|
||||
AppCenter.setEnabled(false)
|
||||
} else {
|
||||
if (!AppCenter.isConfigured()) {
|
||||
activity.startAnalysisInternal()
|
||||
}
|
||||
AppCenter.setEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.checkUpdateEnabled.addTextChangedListener {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val newValue = EnabledType.valueOf(it).boolValue
|
||||
Settings.checkUpdateEnabled = newValue
|
||||
if (!newValue) {
|
||||
Distribute.disableAutomaticCheckForUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.communityButton.setOnClickListener {
|
||||
it.context.launchCustomTab("https://community.sagernet.org/")
|
||||
}
|
||||
binding.documentationButton.setOnClickListener {
|
||||
it.context.launchCustomTab("http://sing-box.sagernet.org/installation/clients/sfa/")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadSettings() {
|
||||
val activity = activity ?: return
|
||||
val dataSize = Libbox.formatBytes(
|
||||
(activity.getExternalFilesDir(null) ?: activity.filesDir)
|
||||
.walkTopDown().filter { it.isFile }.map { it.length() }.sum()
|
||||
)
|
||||
val appCenterEnabled = Settings.analyticsAllowed == Settings.ANALYSIS_ALLOWED
|
||||
val checkUpdateEnabled = Settings.checkUpdateEnabled
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.dataSizeText.text = dataSize
|
||||
binding.appCenterEnabled.text = EnabledType.from(appCenterEnabled).name
|
||||
binding.appCenterEnabled.setSimpleItems(R.array.enabled)
|
||||
binding.checkUpdateEnabled.isEnabled = appCenterEnabled
|
||||
binding.checkUpdateEnabled.text = EnabledType.from(checkUpdateEnabled).name
|
||||
binding.checkUpdateEnabled.setSimpleItems(R.array.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package io.nekohasekai.sfa.ui.profile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.UpdateProfileWork
|
||||
import io.nekohasekai.sfa.constant.EnabledType
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
import io.nekohasekai.sfa.database.Profiles
|
||||
import io.nekohasekai.sfa.database.TypedProfile
|
||||
import io.nekohasekai.sfa.databinding.ActivityEditProfileBinding
|
||||
import io.nekohasekai.sfa.ktx.addTextChangedListener
|
||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||
import io.nekohasekai.sfa.ktx.setSimpleItems
|
||||
import io.nekohasekai.sfa.ktx.text
|
||||
import io.nekohasekai.sfa.ui.shared.AbstractActivity
|
||||
import io.nekohasekai.sfa.utils.HTTPClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
class EditProfileActivity : AbstractActivity() {
|
||||
|
||||
private var _binding: ActivityEditProfileBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var _profile: Profile? = null
|
||||
private val profile get() = _profile!!
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setTitle(R.string.title_edit_profile)
|
||||
_binding = ActivityEditProfileBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
loadProfile()
|
||||
}.onFailure {
|
||||
errorDialogBuilder(it)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadProfile() {
|
||||
delay(200L)
|
||||
|
||||
val profileId = intent.getLongExtra("profile_id", -1L)
|
||||
if (profileId == -1L) error("invalid arguments")
|
||||
_profile = Profiles.get(profileId) ?: error("invalid arguments")
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.name.text = profile.name
|
||||
binding.name.addTextChangedListener {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
profile.name = it
|
||||
Profiles.update(profile)
|
||||
} catch (e: Exception) {
|
||||
errorDialogBuilder(e).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.type.text = profile.typed.type.name
|
||||
binding.editButton.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(
|
||||
this@EditProfileActivity,
|
||||
EditProfileContentActivity::class.java
|
||||
).apply {
|
||||
putExtra("profile_id", profile.id)
|
||||
})
|
||||
}
|
||||
when (profile.typed.type) {
|
||||
TypedProfile.Type.Local -> {
|
||||
binding.editButton.isVisible = true
|
||||
binding.remoteFields.isVisible = false
|
||||
}
|
||||
|
||||
TypedProfile.Type.Remote -> {
|
||||
binding.editButton.isVisible = false
|
||||
binding.remoteFields.isVisible = true
|
||||
binding.remoteURL.text = profile.typed.remoteURL
|
||||
binding.lastUpdated.text =
|
||||
DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated)
|
||||
binding.autoUpdate.text = EnabledType.from(profile.typed.autoUpdate).name
|
||||
binding.autoUpdate.setSimpleItems(R.array.enabled)
|
||||
binding.autoUpdateInterval.isVisible = profile.typed.autoUpdate
|
||||
binding.autoUpdateInterval.text = profile.typed.autoUpdateInterval.toString()
|
||||
}
|
||||
}
|
||||
binding.remoteURL.addTextChangedListener(this@EditProfileActivity::updateRemoteURL)
|
||||
binding.autoUpdate.addTextChangedListener(this@EditProfileActivity::updateAutoUpdate)
|
||||
binding.autoUpdateInterval.addTextChangedListener(this@EditProfileActivity::updateAutoUpdateInterval)
|
||||
binding.updateButton.setOnClickListener(this@EditProfileActivity::updateProfile)
|
||||
binding.checkButton.setOnClickListener(this@EditProfileActivity::checkProfile)
|
||||
binding.profileLayout.isVisible = true
|
||||
binding.progressView.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateRemoteURL(newValue: String) {
|
||||
profile.typed.remoteURL = newValue
|
||||
updateProfile()
|
||||
}
|
||||
|
||||
private fun updateAutoUpdate(newValue: String) {
|
||||
val boolValue = EnabledType.valueOf(newValue).boolValue
|
||||
if (profile.typed.autoUpdate == boolValue) {
|
||||
return
|
||||
}
|
||||
binding.autoUpdateInterval.isVisible = boolValue
|
||||
profile.typed.autoUpdate = boolValue
|
||||
if (boolValue) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
UpdateProfileWork.reconfigureUpdater()
|
||||
}
|
||||
}
|
||||
updateProfile()
|
||||
}
|
||||
|
||||
private fun updateAutoUpdateInterval(newValue: String) {
|
||||
if (newValue.isBlank()) {
|
||||
binding.autoUpdateInterval.error = getString(R.string.profile_input_required)
|
||||
return
|
||||
}
|
||||
val intValue = try {
|
||||
newValue.toInt()
|
||||
} catch (e: Exception) {
|
||||
binding.autoUpdateInterval.error = e.localizedMessage
|
||||
return
|
||||
}
|
||||
if (intValue < 15) {
|
||||
binding.autoUpdateInterval.error =
|
||||
getString(R.string.profile_auto_update_interval_minimum_hint)
|
||||
return
|
||||
}
|
||||
binding.autoUpdateInterval.error = null
|
||||
profile.typed.autoUpdateInterval = intValue
|
||||
updateProfile()
|
||||
}
|
||||
|
||||
private fun updateProfile() {
|
||||
binding.progressView.isVisible = true
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
delay(200)
|
||||
try {
|
||||
Profiles.update(profile)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(e).show()
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressView.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProfile(view: View) {
|
||||
binding.progressView.isVisible = true
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val content = HTTPClient().use { it.getString(profile.typed.remoteURL) }
|
||||
Libbox.checkConfig(content)
|
||||
File(profile.typed.path).writeText(content)
|
||||
profile.typed.lastUpdated = Date()
|
||||
Profiles.update(profile)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(e).show()
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.lastUpdated.text =
|
||||
DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated)
|
||||
binding.progressView.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkProfile(button: View) {
|
||||
binding.progressView.isVisible = true
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
delay(200)
|
||||
try {
|
||||
Libbox.checkConfig(File(profile.typed.path).readText())
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(e).show()
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressView.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package io.nekohasekai.sfa.ui.profile
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.blacksquircle.ui.language.json.JsonLanguage
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
import io.nekohasekai.sfa.database.Profiles
|
||||
import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding
|
||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||
import io.nekohasekai.sfa.ui.shared.AbstractActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class EditProfileContentActivity : AbstractActivity() {
|
||||
|
||||
private var _binding: ActivityEditProfileContentBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private var _profile: Profile? = null
|
||||
private val profile get() = _profile!!
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setTitle(R.string.title_edit_configuration)
|
||||
_binding = ActivityEditProfileContentBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
binding.editor.language = JsonLanguage()
|
||||
loadConfiguration()
|
||||
}
|
||||
|
||||
private fun loadConfiguration() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
loadConfiguration0()
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(it)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.edit_configutation_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_undo -> {
|
||||
if (binding.editor.canUndo()) binding.editor.undo()
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_redo -> {
|
||||
if (binding.editor.canRedo()) binding.editor.redo()
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_check -> {
|
||||
binding.progressView.isVisible = true
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Libbox.checkConfig(binding.editor.text.toString())
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
delay(200)
|
||||
binding.progressView.isInvisible = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_format -> {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val content = Libbox.formatConfig(binding.editor.text.toString())
|
||||
if (binding.editor.text.toString() != content) {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.editor.setTextContent(content)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(it).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private suspend fun loadConfiguration0() {
|
||||
delay(200L)
|
||||
|
||||
val profileId = intent.getLongExtra("profile_id", -1L)
|
||||
if (profileId == -1L) error("invalid arguments")
|
||||
_profile = Profiles.get(profileId) ?: error("invalid arguments")
|
||||
val content = File(profile.typed.path).readText()
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.editor.setTextContent(content)
|
||||
binding.editor.addTextChangedListener {
|
||||
binding.progressView.isVisible = true
|
||||
val newContent = it.toString()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
File(profile.typed.path).writeText(newContent)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Main) {
|
||||
errorDialogBuilder(it)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
delay(200)
|
||||
binding.progressView.isInvisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.progressView.isInvisible = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package io.nekohasekai.sfa.ui.profile
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.database.Profile
|
||||
import io.nekohasekai.sfa.database.Profiles
|
||||
import io.nekohasekai.sfa.database.TypedProfile
|
||||
import io.nekohasekai.sfa.databinding.ActivityAddProfileBinding
|
||||
import io.nekohasekai.sfa.ktx.addTextChangedListener
|
||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||
import io.nekohasekai.sfa.ktx.removeErrorIfNotEmpty
|
||||
import io.nekohasekai.sfa.ktx.showErrorIfEmpty
|
||||
import io.nekohasekai.sfa.ktx.startFilesForResult
|
||||
import io.nekohasekai.sfa.ktx.text
|
||||
import io.nekohasekai.sfa.ui.shared.AbstractActivity
|
||||
import io.nekohasekai.sfa.utils.HTTPClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.Date
|
||||
|
||||
class NewProfileActivity : AbstractActivity() {
|
||||
enum class FileSource(val formatted: String) {
|
||||
CreateNew("Create New"),
|
||||
Import("Import");
|
||||
}
|
||||
|
||||
private var _binding: ActivityAddProfileBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val importFile =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { fileURI ->
|
||||
if (fileURI != null) {
|
||||
binding.sourceURL.editText?.setText(fileURI.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setTitle(R.string.title_new_profile)
|
||||
_binding = ActivityAddProfileBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
binding.name.removeErrorIfNotEmpty()
|
||||
binding.type.addTextChangedListener {
|
||||
when (it) {
|
||||
TypedProfile.Type.Local.name -> {
|
||||
binding.localFields.isVisible = true
|
||||
binding.remoteFields.isVisible = false
|
||||
}
|
||||
|
||||
TypedProfile.Type.Remote.name -> {
|
||||
binding.localFields.isVisible = false
|
||||
binding.remoteFields.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.fileSourceMenu.addTextChangedListener {
|
||||
when (it) {
|
||||
FileSource.CreateNew.formatted -> {
|
||||
binding.importFileButton.isVisible = false
|
||||
binding.sourceURL.isVisible = false
|
||||
}
|
||||
|
||||
FileSource.Import.formatted -> {
|
||||
binding.importFileButton.isVisible = true
|
||||
binding.sourceURL.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.importFileButton.setOnClickListener {
|
||||
startFilesForResult(importFile, "application/json")
|
||||
}
|
||||
binding.createProfile.setOnClickListener(this::createProfile)
|
||||
}
|
||||
|
||||
private fun createProfile(view: View) {
|
||||
if (binding.name.showErrorIfEmpty()) {
|
||||
return
|
||||
}
|
||||
when (binding.type.text) {
|
||||
TypedProfile.Type.Local.name -> {
|
||||
when (binding.fileSourceMenu.text) {
|
||||
FileSource.Import.formatted -> {
|
||||
if (binding.sourceURL.showErrorIfEmpty()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TypedProfile.Type.Remote.name -> {
|
||||
if (binding.remoteURL.showErrorIfEmpty()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.progressView.isVisible = true
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
createProfile0()
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressView.isVisible = false
|
||||
errorDialogBuilder(e).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createProfile0() {
|
||||
val typedProfile = TypedProfile()
|
||||
val profile = Profile(name = binding.name.text, typed = typedProfile)
|
||||
profile.userOrder = Profiles.nextOrder()
|
||||
|
||||
when (binding.type.text) {
|
||||
TypedProfile.Type.Local.name -> {
|
||||
typedProfile.type = TypedProfile.Type.Local
|
||||
val configDirectory = File(filesDir, "configs").also { it.mkdirs() }
|
||||
val configFile = File(configDirectory, "${profile.userOrder}.json")
|
||||
when (binding.fileSourceMenu.text) {
|
||||
FileSource.CreateNew.formatted -> {
|
||||
configFile.writeText("{}")
|
||||
}
|
||||
|
||||
FileSource.Import.formatted -> {
|
||||
val sourceURL = binding.sourceURL.text
|
||||
val content = if (sourceURL.startsWith("content://")) {
|
||||
val inputStream =
|
||||
contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream
|
||||
inputStream.use { it.bufferedReader().readText() }
|
||||
} else if (sourceURL.startsWith("file://")) {
|
||||
File(sourceURL).readText()
|
||||
} else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) {
|
||||
HTTPClient().use { it.getString(sourceURL) }
|
||||
} else {
|
||||
error("unsupported source: $sourceURL")
|
||||
}
|
||||
|
||||
Libbox.checkConfig(content)
|
||||
configFile.writeText(content)
|
||||
}
|
||||
}
|
||||
typedProfile.path = configFile.path
|
||||
}
|
||||
|
||||
TypedProfile.Type.Remote.name -> {
|
||||
typedProfile.type = TypedProfile.Type.Remote
|
||||
val configDirectory = File(filesDir, "configs").also { it.mkdirs() }
|
||||
val configFile = File(configDirectory, "${profile.userOrder}.json")
|
||||
val remoteURL = binding.remoteURL.text
|
||||
val content = HTTPClient().use { it.getString(remoteURL) }
|
||||
Libbox.checkConfig(content)
|
||||
configFile.writeText(content)
|
||||
typedProfile.path = configFile.path
|
||||
typedProfile.remoteURL = remoteURL
|
||||
typedProfile.lastUpdated = Date()
|
||||
}
|
||||
}
|
||||
Profiles.create(profile)
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.progressView.isVisible = false
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package io.nekohasekai.sfa.ui.shared
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.elevation.SurfaceColors
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.ktx.getAttrColor
|
||||
|
||||
|
||||
abstract class AbstractActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
DynamicColors.applyToActivityIfAvailable(this)
|
||||
|
||||
val color = SurfaceColors.SURFACE_2.getColor(this)
|
||||
window.statusBarColor = color
|
||||
window.navigationBarColor = color
|
||||
|
||||
supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable(
|
||||
this@AbstractActivity,
|
||||
R.drawable.ic_arrow_back_24
|
||||
)!!.apply {
|
||||
setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface))
|
||||
})
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user