diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5216a01e96..82c7389086 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -116,9 +116,7 @@ android:label="@string/proxy" /> + android:exported="false" /> diff --git a/app/src/main/java/com/github/kr328/clash/ProvidersActivity.kt b/app/src/main/java/com/github/kr328/clash/ProvidersActivity.kt index da9bb35fcf..440165d16b 100644 --- a/app/src/main/java/com/github/kr328/clash/ProvidersActivity.kt +++ b/app/src/main/java/com/github/kr328/clash/ProvidersActivity.kt @@ -1,12 +1,10 @@ package com.github.kr328.clash import com.github.kr328.clash.common.util.intent -import com.github.kr328.clash.common.util.ticker import com.github.kr328.clash.design.ProvidersDesign import com.github.kr328.clash.design.R import com.github.kr328.clash.design.util.showExceptionToast import com.github.kr328.clash.util.withClash -import java.util.concurrent.TimeUnit import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select @@ -18,8 +16,6 @@ class ProvidersActivity : BaseActivity() { setContentDesign(design) - val ticker = ticker(TimeUnit.MINUTES.toMillis(1)) - while (isActive) { select { events.onReceive { @@ -55,9 +51,6 @@ class ProvidersActivity : BaseActivity() { } } } - if (activityStarted) { - ticker.onReceive { design.updateElapsed() } - } } } } diff --git a/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt b/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt index 8c45aa097e..a943c8f156 100644 --- a/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt +++ b/design/src/main/java/com/github/kr328/clash/design/ProvidersDesign.kt @@ -2,15 +2,49 @@ package com.github.kr328.clash.design import android.content.Context import android.view.View +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import com.github.kr328.clash.core.model.Provider -import com.github.kr328.clash.design.adapter.ProviderAdapter -import com.github.kr328.clash.design.databinding.DesignProvidersBinding -import com.github.kr328.clash.design.util.applyFrom -import com.github.kr328.clash.design.util.applyLinearAdapter -import com.github.kr328.clash.design.util.bindAppBarElevation -import com.github.kr328.clash.design.util.layoutInflater -import com.github.kr328.clash.design.util.root +import com.github.kr328.clash.design.component.MihomoScaffold +import com.github.kr328.clash.design.ui.theme.MihomoTheme +import com.github.kr328.clash.design.ui.theme.PreviewMihomo +import com.github.kr328.clash.design.util.elapsedIntervalString +import com.github.kr328.clash.design.util.type +import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext class ProvidersDesign(context: Context, providers: List) : @@ -19,45 +53,178 @@ class ProvidersDesign(context: Context, providers: List) : data class Update(val index: Int, val provider: Provider) : Request() } - private val binding = DesignProvidersBinding.inflate(context.layoutInflater, context.root, false) + private val states = + mutableStateListOf().apply { + addAll( + providers.map { + ProviderItemState(provider = it, updatedAt = it.updatedAt, updating = false) + } + ) + } + + override val root: View by composeView { + MihomoTheme { + ProvidersScreen( + states = states, + onUpdateAll = ::requestUpdateAll, + onUpdate = { index, provider -> + states[index] = states[index].copy(updating = true) + requests.trySend(Request.Update(index, provider)) + }, + ) + } + } - override val root: View - get() = binding.root + suspend fun notifyUpdated(index: Int) = + withContext(Dispatchers.Main) { states[index] = states[index].copy(updating = false) } - private val adapter = - ProviderAdapter(context, providers) { index, provider -> - requests.trySend(Request.Update(index, provider)) + suspend fun notifyChanged(index: Int) = + withContext(Dispatchers.Main) { + states[index] = states[index].copy(updating = false, updatedAt = System.currentTimeMillis()) } - fun updateElapsed() { - adapter.updateElapsed() + private fun requestUpdateAll() { + states.forEachIndexed { index, state -> + if (state.updating || state.provider.vehicleType == Provider.VehicleType.Inline) { + return@forEachIndexed + } + + states[index] = state.copy(updating = true) + requests.trySend(Request.Update(index, state.provider)) + } } +} + +private data class ProviderItemState( + val provider: Provider, + val updatedAt: Long, + val updating: Boolean, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProvidersScreen( + states: List, + onUpdateAll: () -> Unit, + onUpdate: (Int, Provider) -> Unit, +) { + var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) } - suspend fun notifyUpdated(index: Int) { - withContext(Dispatchers.Main) { adapter.notifyUpdated(index) } + LaunchedEffect(Unit) { + while (true) { + delay(1.minutes) + currentTime = System.currentTimeMillis() + } } - suspend fun notifyChanged(index: Int) { - withContext(Dispatchers.Main) { adapter.notifyChanged(index) } + MihomoScaffold( + title = stringResource(R.string.providers), + actions = { + IconButton(onClick = onUpdateAll) { + Icon( + painter = painterResource(R.drawable.ic_baseline_sync), + contentDescription = stringResource(R.string.update_all), + ) + } + }, + ) { innerPadding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + itemsIndexed( + items = states, + key = { _, state -> "${state.provider.type}-${state.provider.name}" }, + ) { index, state -> + ProviderItem( + state = state, + currentTime = currentTime, + onUpdate = { onUpdate(index, state.provider) }, + ) + } + } } +} - init { - binding.self = this +@Composable +private fun ProviderItem(state: ProviderItemState, currentTime: Long, onUpdate: () -> Unit) { + val context = LocalContext.current + val itemMinHeight = dimensionResource(R.dimen.item_min_height) + val itemHeaderMargin = dimensionResource(R.dimen.item_header_margin) + val itemTextMargin = dimensionResource(R.dimen.item_text_margin) + val itemMiddleMargin = dimensionResource(R.dimen.item_midden_margin) - binding.activityBarLayout.applyFrom(context) + val canUpdate = state.provider.vehicleType != Provider.VehicleType.Inline - binding.mainList.recyclerList.bindAppBarElevation(binding.activityBarLayout) - binding.mainList.recyclerList.applyLinearAdapter(context, adapter) - } + Row( + modifier = + Modifier.fillMaxWidth().heightIn(min = itemMinHeight).padding(start = itemHeaderMargin), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = state.provider.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(modifier = Modifier.height(itemTextMargin)) + Text(text = state.provider.type(context), style = MaterialTheme.typography.bodyMedium) + } - fun requestUpdateAll() { - adapter.states - .filter { !it.updating } - .forEachIndexed { index, state -> - state.updating = true - if (state.provider.vehicleType != Provider.VehicleType.Inline) { - requests.trySend(Request.Update(index, state.provider)) + if (canUpdate) { + Text( + text = (currentTime - state.updatedAt).elapsedIntervalString(context), + modifier = Modifier.padding(end = itemMiddleMargin), + ) + Box( + modifier = + Modifier.size(width = dimensionResource(R.dimen.divider_size), height = itemMinHeight) + .background(MaterialTheme.colorScheme.outline) + ) + IconButton( + onClick = onUpdate, + enabled = !state.updating, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + if (state.updating) { + CircularProgressIndicator( + modifier = Modifier.size(dimensionResource(R.dimen.item_tailing_component_size)), + strokeWidth = 2.dp, + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_baseline_swap_vert), + contentDescription = stringResource(R.string.update), + ) } } + } } } + +@PreviewMihomo +@Composable +private fun ProvidersScreenPreview() = MihomoTheme { + ProvidersScreen( + states = + listOf( + ProviderItemState( + provider = + Provider( + name = "Proxy Provider", + type = Provider.Type.Proxy, + vehicleType = Provider.VehicleType.HTTP, + updatedAt = System.currentTimeMillis() - 10 * 60 * 1000, + ), + updatedAt = System.currentTimeMillis() - 10 * 60 * 1000, + updating = false, + ), + ProviderItemState( + provider = + Provider( + name = "Inline Rules", + type = Provider.Type.Rule, + vehicleType = Provider.VehicleType.Inline, + updatedAt = System.currentTimeMillis() - 60 * 60 * 1000, + ), + updatedAt = System.currentTimeMillis() - 60 * 60 * 1000, + updating = false, + ), + ), + onUpdateAll = {}, + onUpdate = { _, _ -> }, + ) +} diff --git a/design/src/main/java/com/github/kr328/clash/design/adapter/ProviderAdapter.kt b/design/src/main/java/com/github/kr328/clash/design/adapter/ProviderAdapter.kt deleted file mode 100644 index e87329c8fd..0000000000 --- a/design/src/main/java/com/github/kr328/clash/design/adapter/ProviderAdapter.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.github.kr328.clash.design.adapter - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.github.kr328.clash.core.model.Provider -import com.github.kr328.clash.design.databinding.AdapterProviderBinding -import com.github.kr328.clash.design.model.ProviderState -import com.github.kr328.clash.design.ui.ObservableCurrentTime -import com.github.kr328.clash.design.util.layoutInflater - -class ProviderAdapter( - private val context: Context, - providers: List, - private val requestUpdate: (Int, Provider) -> Unit, -) : RecyclerView.Adapter() { - class Holder(val binding: AdapterProviderBinding) : RecyclerView.ViewHolder(binding.root) - - private val currentTime = ObservableCurrentTime() - - val states = providers.map { ProviderState(it, it.updatedAt, false) } - - fun updateElapsed() { - currentTime.update() - } - - fun notifyUpdated(index: Int) { - states[index].apply { updating = false } - - notifyItemChanged(index) - } - - fun notifyChanged(index: Int) { - states[index].apply { - updating = false - updatedAt = System.currentTimeMillis() - } - - notifyItemChanged(index) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - return Holder( - AdapterProviderBinding.inflate(context.layoutInflater, parent, false).also { - it.currentTime = currentTime - } - ) - } - - override fun onBindViewHolder(holder: Holder, position: Int) { - val state = states[position] - - holder.binding.provider = state.provider - holder.binding.state = state - if (state.provider.vehicleType == Provider.VehicleType.Inline) { - holder.binding.endView.visibility = View.GONE - holder.binding.elapsedView.visibility = View.GONE - holder.binding.divider.visibility = View.GONE - } else { - holder.binding.endView.visibility = View.VISIBLE - holder.binding.elapsedView.visibility = View.VISIBLE - holder.binding.update = View.OnClickListener { - state.updating = true - requestUpdate(position, state.provider) - } - } - } - - override fun getItemCount(): Int { - return states.size - } -} diff --git a/design/src/main/res/layout/adapter_provider.xml b/design/src/main/res/layout/adapter_provider.xml deleted file mode 100644 index 4625574f6e..0000000000 --- a/design/src/main/res/layout/adapter_provider.xml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/design/src/main/res/layout/design_providers.xml b/design/src/main/res/layout/design_providers.xml deleted file mode 100644 index c33e7e9473..0000000000 --- a/design/src/main/res/layout/design_providers.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file