owenclave / app/src/main/java/io/nekohasekai/sagernet/ui/AssetsActivity.kt

github - dev - back to tree - upstream
/****************************************************************************** * * * Copyright (C) 2021 by nekohasekai * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * * * ******************************************************************************/ package io.nekohasekai.sagernet.ui import android.content.Intent import android.os.Bundle import android.provider.OpenableColumns import android.text.format.DateFormat import android.view.Menu import android.view.MenuItem import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.databinding.LayoutAssetItemBinding import io.nekohasekai.sagernet.databinding.LayoutAssetsBinding import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.widget.UndoSnackbarManager import libexclavecore.Libexclavecore import java.io.File import java.util.* import java.util.concurrent.atomic.AtomicInteger class AssetsActivity : ThemedActivity() { lateinit var adapter: AssetAdapter lateinit var layout: LayoutAssetsBinding lateinit var undoManager: UndoSnackbarManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = LayoutAssetsBinding.inflate(layoutInflater) layout = binding setContentView(binding.root) ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.recycler_view)) { v, insets -> val bars = insets.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() ) v.updatePadding( left = bars.left + dp2px(4), right = bars.right + dp2px(4), bottom = bars.bottom + dp2px(4), ) insets } setSupportActionBar(findViewById(R.id.toolbar)) supportActionBar?.apply { setTitle(R.string.route_assets) setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(R.drawable.ic_navigation_close) } binding.recyclerView.layoutManager = FixedLinearLayoutManager(binding.recyclerView) adapter = AssetAdapter() binding.recyclerView.adapter = adapter binding.refreshLayout.setOnRefreshListener { adapter.reloadAssets() binding.refreshLayout.isRefreshing = false } binding.refreshLayout.setColorSchemeColors(getColorAttr(R.attr.primaryOrTextPrimary)) undoManager = UndoSnackbarManager(this, adapter) ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( 0, ItemTouchHelper.START ) { override fun getSwipeDirs( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val index = viewHolder.adapterPosition if (index < 2) return 0 return super.getSwipeDirs(recyclerView, viewHolder) } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val index = viewHolder.adapterPosition adapter.remove(index) undoManager.remove(index to (viewHolder as AssetHolder).file) } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ) = false }).attachToRecyclerView(binding.recyclerView) } override fun snackbarInternal(text: CharSequence): Snackbar { return Snackbar.make(layout.coordinator, text, Snackbar.LENGTH_LONG) } val internalFiles = arrayOf("geoip.dat", "geosite.dat") override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.import_asset_menu, menu) return true } val importFile = registerForActivityResult(ActivityResultContracts.GetContent()) { file -> if (file != null) { val fileName = contentResolver.query(file, null, null, null, null)?.use { cursor -> cursor.moveToFirst() cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) }?.takeIf { it.isNotBlank() } ?: file.pathSegments.last() .substringAfterLast('/') .substringAfter(':') if (!fileName.endsWith(".dat") && fileName != "index.html" && fileName != "index.js" && fileName != "root_store.certs") { runOnMainDispatcher { alert(getString(R.string.route_not_asset, fileName)).show() } return@registerForActivityResult } runOnDefaultDispatcher { val outFile = File(app.externalAssets, fileName).apply { parentFile?.mkdirs() } contentResolver.openInputStream(file)?.use(outFile.outputStream()) File(outFile.parentFile, outFile.nameWithoutExtension + ".version.txt").apply { if (isFile) delete() } adapter.reloadAssets() } } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_import_file -> { startFilesForResult(importFile, "*/*") return true } R.id.action_import_url -> { startActivity(Intent(this, AssetEditActivity::class.java)) adapter.reloadAssets() return true } } return false } inner class AssetAdapter : RecyclerView.Adapter(), UndoSnackbarManager.Interface { val assets = ArrayList() init { reloadAssets() } fun reloadAssets() { assets.clear() assets.add(File(app.externalAssets, "geoip.dat")) assets.add(File(app.externalAssets, "geosite.dat")) val managedAssets = SagerDatabase.assetDao.getAll().associateBy { it.name } managedAssets.forEach { assets.add(File(app.externalAssets, it.key)) } val unmanagedAssets = app.externalAssets.listFiles()?.filter { it.isFile && it.name.endsWith(".dat") && it.name !in internalFiles && it !in assets } if (unmanagedAssets != null) assets.addAll(unmanagedAssets) layout.refreshLayout.post { notifyDataSetChanged() } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AssetHolder { return AssetHolder(LayoutAssetItemBinding.inflate(layoutInflater, parent, false)) } override fun onBindViewHolder(holder: AssetHolder, position: Int) { holder.bind(assets[position]) } override fun getItemCount(): Int { return assets.size } fun remove(index: Int) { assets.removeAt(index) notifyItemRemoved(index) } override fun undo(actions: List>) { for ((index, item) in actions) { assets.add(index, item) notifyItemInserted(index) } } override fun commit(actions: List>) { val groups = actions.map { it.second }.toTypedArray() runOnDefaultDispatcher { groups.forEach { it.deleteRecursively() SagerDatabase.assetDao.delete(it.name) } } } } val updating = AtomicInteger() inner class AssetHolder(val binding: LayoutAssetItemBinding) : RecyclerView.ViewHolder(binding.root) { lateinit var file: File fun bind(file: File) { this.file = file binding.assetName.text = file.name val versionFile = File(file.parentFile, "${file.nameWithoutExtension}.version.txt") val localVersion = if (file.isFile) { if (versionFile.isFile) { versionFile.readText().trim() } else { DateFormat.getDateFormat(app).format(Date(file.lastModified())) } } else { "" } binding.assetStatus.text = getString(R.string.route_asset_status, localVersion) val assetEntity = SagerDatabase.assetDao.get(file.name) binding.rulesUpdate.isInvisible = file.name !in internalFiles && assetEntity == null binding.rulesUpdate.setOnClickListener { updating.incrementAndGet() layout.refreshLayout.isEnabled = false binding.subscriptionUpdateProgress.isInvisible = false binding.rulesUpdate.isInvisible = true runOnDefaultDispatcher { runCatching { if (file.name in internalFiles) { updateAsset(file, versionFile, localVersion) } else { updateCustomAsset(file, assetEntity!!.url) } }.onFailure { onMainDispatcher { snackbar(it.readableMessage).show() } } onMainDispatcher { binding.rulesUpdate.isInvisible = false binding.subscriptionUpdateProgress.isInvisible = true if (updating.decrementAndGet() == 0) { layout.refreshLayout.isEnabled = true } } } } binding.edit.isVisible = file.name !in internalFiles && assetEntity != null binding.edit.setOnClickListener { startActivity(Intent(this@AssetsActivity, AssetEditActivity::class.java).apply { putExtra(AssetEditActivity.EXTRA_ASSET_NAME, file.name) }) adapter.reloadAssets() } } } suspend fun updateAsset(file: File, versionFile: File, localVersion: String) { val repo: String var fileName = file.name when (DataStore.rulesProvider) { 3 -> return updateGeoAsset(file, versionFile) 0 -> { if (file.name == internalFiles[0]) { repo = "v2fly/geoip" } else { repo = "v2fly/domain-list-community" fileName = "dlc.dat" } } 1 -> repo = "Loyalsoldier/v2ray-rules-dat" 2 -> repo = "Chocolate4U/Iran-v2ray-rules" else -> error("invalid asset provider") } val client = Libexclavecore.newHttpClient().apply { keepAlive() if (SagerNet.started && DataStore.startedProfile > 0) { useUDS(SagerNet.deviceStorage.noBackupFilesDir.toString() + "/ipc.sock") } } try { var response = client.newRequest().apply { setURL("https://api.github.com/repos/$repo/releases/latest") }.execute() val release = parseJson(response.contentString).asJsonObject val tagName = release.getString("tag_name")?: error("tag_name not found in release ${release["url"]}") if (tagName == localVersion) { onMainDispatcher { snackbar(R.string.route_asset_no_update).show() } return } val releaseAssets = release.getArray("assets") val assetToDownload = releaseAssets?.find { it.getString("name") == fileName } ?: error("File $fileName not found in release ${release["url"]}") val browserDownloadUrl = assetToDownload.getString("browser_download_url") response = client.newRequest().apply { setURL(browserDownloadUrl) }.execute() val cacheFile = File(file.parentFile, file.name + ".tmp") cacheFile.parentFile?.mkdirs() response.writeTo(cacheFile.canonicalPath) cacheFile.renameTo(file) versionFile.writeText(tagName) adapter.reloadAssets() onMainDispatcher { snackbar(R.string.route_asset_updated).show() } } finally { client.close() } } suspend fun updateGeoAsset(file: File, versionFile: File) { try { updateCustomAsset(file, if (file.name == internalFiles[0]) DataStore.rulesGeoipUrl else DataStore.rulesGeositeUrl) } finally { if (versionFile.isFile) { versionFile.delete() } } } suspend fun updateCustomAsset(file: File, url: String) { val client = Libexclavecore.newHttpClient().apply { keepAlive() if (SagerNet.started && DataStore.startedProfile > 0) { useUDS(SagerNet.deviceStorage.noBackupFilesDir.toString() + "/ipc.sock") } } try { val response = client.newRequest().apply { setURL(url) }.execute() val cacheFile = File(file.parentFile, file.name + ".tmp") cacheFile.parentFile?.mkdirs() response.writeTo(cacheFile.canonicalPath) cacheFile.renameTo(file) adapter.reloadAssets() onMainDispatcher { snackbar(R.string.route_asset_updated).show() } } finally { client.close() } } override fun onSupportNavigateUp(): Boolean { finish() return true } override fun onResume() { super.onResume() if (::adapter.isInitialized) { adapter.reloadAssets() } } }