From db0c5da73afec98790eb188ad856effe73f402d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Mon, 21 Nov 2022 10:33:41 +0800 Subject: [PATCH 01/11] Add "crossOrigin" support to all APIs to support the web client. --- .../android/assistant/server/controller/AudioController.kt | 2 ++ .../android/assistant/server/controller/CommonController.kt | 2 ++ .../android/assistant/server/controller/ContactController.kt | 2 ++ .../android/assistant/server/controller/FileController.kt | 1 + .../android/assistant/server/controller/ImageController.kt | 2 ++ .../android/assistant/server/controller/StreamController.kt | 2 ++ .../android/assistant/server/controller/VideoController.kt | 1 + 7 files changed, 12 insertions(+) diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt index c99564d..77a84f4 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt @@ -3,6 +3,7 @@ package com.youngfeng.android.assistant.server.controller import android.Manifest import android.media.MediaScannerConnection import android.text.TextUtils +import com.yanzhenjie.andserver.annotation.CrossOrigin import com.yanzhenjie.andserver.annotation.GetMapping import com.yanzhenjie.andserver.annotation.PathVariable import com.yanzhenjie.andserver.annotation.PostMapping @@ -34,6 +35,7 @@ import pub.devrel.easypermissions.EasyPermissions import java.io.File import java.util.Locale +@CrossOrigin @RestController @RequestMapping("/audio") class AudioController { diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt index 877409a..4bba783 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt @@ -4,6 +4,7 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Build import android.text.TextUtils +import com.yanzhenjie.andserver.annotation.CrossOrigin import com.yanzhenjie.andserver.annotation.PostMapping import com.yanzhenjie.andserver.annotation.RequestBody import com.yanzhenjie.andserver.annotation.RequestMapping @@ -30,6 +31,7 @@ import timber.log.Timber import java.io.File import java.util.Locale +@CrossOrigin @RestController @RequestMapping("/common") class CommonController { diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/ContactController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/ContactController.kt index bb27bb1..338b0b4 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/ContactController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/ContactController.kt @@ -2,6 +2,7 @@ package com.youngfeng.android.assistant.server.controller import android.Manifest import android.accounts.Account +import com.yanzhenjie.andserver.annotation.CrossOrigin import com.yanzhenjie.andserver.annotation.PostMapping import com.yanzhenjie.andserver.annotation.RequestBody import com.yanzhenjie.andserver.annotation.RequestMapping @@ -60,6 +61,7 @@ import org.greenrobot.eventbus.EventBus import pub.devrel.easypermissions.EasyPermissions import timber.log.Timber +@CrossOrigin @RestController @RequestMapping("/contact") class ContactController { diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt index 36ea6b9..bcd2c95 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt @@ -28,6 +28,7 @@ import java.io.* import java.lang.Exception import java.util.Locale +@CrossOrigin @RestController @RequestMapping("/file") open class FileController { diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt index d78b481..e805d82 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt @@ -2,6 +2,7 @@ package com.youngfeng.android.assistant.server.controller import android.media.MediaScannerConnection import android.text.TextUtils +import com.yanzhenjie.andserver.annotation.CrossOrigin import com.yanzhenjie.andserver.annotation.PostMapping import com.yanzhenjie.andserver.annotation.RequestBody import com.yanzhenjie.andserver.annotation.RequestMapping @@ -27,6 +28,7 @@ import java.io.File import java.lang.Exception import java.util.Locale +@CrossOrigin @RestController @RequestMapping("/image") class ImageController { diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/StreamController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/StreamController.kt index 185badb..8cb042e 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/StreamController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/StreamController.kt @@ -12,6 +12,7 @@ import android.util.Log import android.util.Size import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import com.yanzhenjie.andserver.annotation.CrossOrigin import com.yanzhenjie.andserver.annotation.GetMapping import com.yanzhenjie.andserver.annotation.PathVariable import com.yanzhenjie.andserver.annotation.QueryParam @@ -44,6 +45,7 @@ import timber.log.Timber import java.io.File import java.net.URLEncoder +@CrossOrigin @RestController @RequestMapping("/stream") class StreamController { diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt index 7d25ff3..86c1673 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt @@ -38,6 +38,7 @@ import java.io.File import java.lang.Exception import java.util.Locale +@CrossOrigin @RestController @RequestMapping("/video") class VideoController { From f0696e8b564978b7c3af1793e784a4f8dda76aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Wed, 23 Nov 2022 12:40:20 +0800 Subject: [PATCH 02/11] * Add "Set web connection" page to control accessing with web. * Add back to previous page icon for all pages. --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 10 +- .../android/assistant/MainActivity.kt | 7 + .../android/assistant/about/AboutActivity.kt | 11 + .../connection/view/ConnectionActivity.kt | 155 ++++++++++++++ .../viewmodel/ConnectionViewModel.kt | 48 +++++ .../assistant/manager/AccessControlManager.kt | 69 +++++++ .../assistant/popmenu/CheckMenuAdapter.kt | 41 ++++ .../assistant/popmenu/CheckMenuItem.kt | 6 + .../android/assistant/scan/ScanActivity.kt | 14 +- .../server/controller/CommonController.kt | 4 + .../android/assistant/util/CommonUtil.kt | 9 + .../res/drawable/background_edit_text.xml | 5 + .../main/res/drawable/background_round.xml | 6 + .../main/res/layout/activity_connection.xml | 192 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 2 + app/src/main/res/layout/check_menu_item.xml | 37 ++++ app/src/main/res/layout/select.xml | 21 ++ app/src/main/res/menu/main.xml | 4 + .../main/res/mipmap-xxhdpi/ic_check_mark.png | Bin 0 -> 1049 bytes .../res/mipmap-xxhdpi/ic_check_mark_thin.png | Bin 0 -> 921 bytes .../main/res/mipmap-xxhdpi/ic_close_mark.png | Bin 0 -> 1009 bytes app/src/main/res/mipmap-xxhdpi/ic_connect.png | Bin 0 -> 1602 bytes app/src/main/res/mipmap-xxhdpi/ic_eye.png | Bin 0 -> 911 bytes app/src/main/res/mipmap-xxhdpi/ic_up_down.png | Bin 0 -> 744 bytes app/src/main/res/values-en/strings.xml | 10 + app/src/main/res/values-night/themes.xml | 15 +- app/src/main/res/values/strings.xml | 10 + app/src/main/res/values/themes.xml | 4 +- 29 files changed, 668 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/youngfeng/android/assistant/connection/view/ConnectionActivity.kt create mode 100644 app/src/main/java/com/youngfeng/android/assistant/connection/viewmodel/ConnectionViewModel.kt create mode 100644 app/src/main/java/com/youngfeng/android/assistant/manager/AccessControlManager.kt create mode 100644 app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuAdapter.kt create mode 100644 app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuItem.kt create mode 100644 app/src/main/res/drawable/background_edit_text.xml create mode 100644 app/src/main/res/drawable/background_round.xml create mode 100644 app/src/main/res/layout/activity_connection.xml create mode 100644 app/src/main/res/layout/check_menu_item.xml create mode 100644 app/src/main/res/layout/select.xml create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_check_mark.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_check_mark_thin.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_close_mark.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_connect.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_eye.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_up_down.png diff --git a/app/build.gradle b/app/build.gradle index d70dcde..d502623 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -56,7 +56,7 @@ android { reports { html { enabled = true - destination = file("build/reports/detekt/detekt.html") + destination = file("build/reports/detekt/detekt.html") } } } @@ -82,6 +82,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.yanzhenjie.andserver:api:2.1.10' + implementation 'androidx.test:core-ktx:1.4.0' kapt 'com.yanzhenjie.andserver:processor:2.1.10' implementation 'javax.activation:javax.activation-api:1.2.0' @@ -104,6 +105,7 @@ dependencies { implementation 'com.github.bingoogolapple.BGAQRCode-Android:zxing:1.3.8' implementation 'pub.devrel:easypermissions:3.0.0' implementation 'com.jakewharton.timber:timber:5.0.1' + implementation "com.github.skydoves:powermenu:2.2.1" debugImplementation 'com.amitshekhar.android:debug-db:1.0.6' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 367790e..ad4fab4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -10,13 +11,16 @@ - + + + + diff --git a/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt index 0069403..e2fd97e 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import com.youngfeng.android.assistant.about.AboutActivity +import com.youngfeng.android.assistant.connection.view.ConnectionActivity import com.youngfeng.android.assistant.databinding.ActivityMainBinding import com.youngfeng.android.assistant.event.BatchUninstallEvent import com.youngfeng.android.assistant.event.DeviceConnectEvent @@ -348,6 +349,12 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { startActivity(intent) return true } + + if (item.itemId == R.id.menu_connect) { + val intent = Intent(this, ConnectionActivity::class.java) + startActivity(intent) + return true + } return super.onOptionsItemSelected(item) } diff --git a/app/src/main/java/com/youngfeng/android/assistant/about/AboutActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/about/AboutActivity.kt index 7cf29ea..edcba04 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/about/AboutActivity.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/about/AboutActivity.kt @@ -1,6 +1,7 @@ package com.youngfeng.android.assistant.about import android.os.Bundle +import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatTextView import com.youngfeng.android.assistant.BuildConfig @@ -13,6 +14,16 @@ class AboutActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_about) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + mVersionText.text = BuildConfig.VERSION_NAME } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/connection/view/ConnectionActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/connection/view/ConnectionActivity.kt new file mode 100644 index 0000000..b7cd41a --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/connection/view/ConnectionActivity.kt @@ -0,0 +1,155 @@ +package com.youngfeng.android.assistant.connection.view + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.net.wifi.WifiManager +import android.os.Bundle +import android.text.InputType +import android.text.format.Formatter +import android.view.View +import android.widget.TextView +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import com.skydoves.powermenu.CustomPowerMenu +import com.skydoves.powermenu.OnMenuItemClickListener +import com.youngfeng.android.assistant.R +import com.youngfeng.android.assistant.connection.viewmodel.ConnectionViewModel +import com.youngfeng.android.assistant.databinding.ActivityConnectionBinding +import com.youngfeng.android.assistant.manager.AccessControlManager +import com.youngfeng.android.assistant.popmenu.CheckMenuAdapter +import com.youngfeng.android.assistant.popmenu.CheckMenuItem + +class ConnectionActivity : AppCompatActivity() { + private var mViewDataBinding: ActivityConnectionBinding? = null + private val mViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mViewDataBinding = DataBindingUtil.setContentView(this, R.layout.activity_connection) + + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.setDisplayShowTitleEnabled(false) + + mViewModel.init() + + mViewDataBinding?.apply { + this.lifecycleOwner = this@ConnectionActivity + this.viewModel = mViewModel + + selectAccessState.setOnClickListener { + showAccessStateMenu(it) + } + + selectPwd.setOnClickListener { + showPwdStateMenu(it) + } + + imageFinish.setOnClickListener { + finish() + } + + imageCommit.setOnClickListener { + submitPasswd() + } + } + + initObservers() + initWifiManager() + } + + private fun submitPasswd() { + AccessControlManager.getInstance().password = mViewModel.passwd.value + finish() + } + + private fun showAccessStateMenu(anchor: View) { + val density = this.resources.displayMetrics.density + val menu = + CustomPowerMenu.Builder(this, CheckMenuAdapter()) + .addItem( + CheckMenuItem( + text = getString(R.string.allow), + isChecked = mViewModel.allowAccess.value == true + ) + ).addItem( + CheckMenuItem( + text = getString(R.string.disallow), + isChecked = mViewModel.allowAccess.value == false + ) + ).setMenuRadius(15 * density).setWidth((density * 187).toInt()).build() + + menu.onMenuItemClickListener = OnMenuItemClickListener { position, item -> + if (item?.isChecked == false) { + if (position == 0) { + mViewModel.setAllowAccess(true) + } else { + mViewModel.setAllowAccess(false) + } + menu?.dismiss() + } + } + menu.showAsAnchorCenter(anchor) + } + + private fun showPwdStateMenu(anchor: View) { + val density = this.resources.displayMetrics.density + val menu = + CustomPowerMenu.Builder(this, CheckMenuAdapter()) + .addItem( + CheckMenuItem( + text = getString(R.string.no_pwd), + isChecked = mViewModel.enablePwd.value == false + ) + ).addItem( + CheckMenuItem( + text = getString(R.string.password), + isChecked = mViewModel.enablePwd.value == true + ) + ).setMenuRadius(15 * density).setWidth((density * 187).toInt()) + .setLifecycleOwner(this).setFocusable(true).build() + menu.onMenuItemClickListener = OnMenuItemClickListener { position, item -> + if (item?.isChecked == false) { + if (position == 0) { + mViewModel.setEnablePwd(false) + } else { + mViewModel.setEnablePwd(true) + } + menu.dismiss() + } + } + menu.showAsAnchorCenter(anchor) + } + + private fun initWifiManager() { + val wifiManager = + this@ConnectionActivity.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val wifiInfo = wifiManager.connectionInfo + mViewModel.setIpAddress(Formatter.formatIpAddress(wifiInfo.ipAddress)) + } + + private fun initObservers() { + mViewModel.allowAccess.observe( + this + ) { allowAccess -> + mViewDataBinding?.selectAccessState?.findViewById(R.id.text_label) + ?.setText(if (allowAccess) R.string.allow else R.string.disallow) + } + + mViewModel.enablePwd.observe(this) { enablePwd -> + mViewDataBinding?.selectPwd?.findViewById(R.id.text_label) + ?.setText(if (enablePwd) R.string.password else R.string.no_pwd) + } + + mViewModel.showPwd.observe(this) { showPwd -> + mViewDataBinding?.editPwd?.inputType = if (showPwd) InputType.TYPE_CLASS_NUMBER else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD + mViewDataBinding?.imagePwd?.imageTintList = ColorStateList.valueOf(if (showPwd) Color.parseColor("#0f84ff") else Color.parseColor("#727272")) + } + + mViewModel.passwd.observe(this) { passwd -> + mViewDataBinding?.imageCommit?.isEnabled = (passwd?.length ?: 0) >= 6 + mViewDataBinding?.imageCommit?.imageTintList = ColorStateList.valueOf(if ((passwd?.length ?: 0) >= 6) Color.parseColor("#333333") else Color.GRAY) + } + } +} diff --git a/app/src/main/java/com/youngfeng/android/assistant/connection/viewmodel/ConnectionViewModel.kt b/app/src/main/java/com/youngfeng/android/assistant/connection/viewmodel/ConnectionViewModel.kt new file mode 100644 index 0000000..3ae443f --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/connection/viewmodel/ConnectionViewModel.kt @@ -0,0 +1,48 @@ + +package com.youngfeng.android.assistant.connection.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.youngfeng.android.assistant.manager.AccessControlManager + +class ConnectionViewModel : ViewModel() { + private val _ipAddress = MutableLiveData() + val ipAddress: LiveData = _ipAddress + + private val _allowAccess = MutableLiveData() + val allowAccess: LiveData = _allowAccess + + private val _enablePwd = MutableLiveData() + val enablePwd: LiveData = _enablePwd + + private val _showPwd = MutableLiveData() + val showPwd: LiveData = _showPwd + + val passwd: MutableLiveData = MutableLiveData() + + fun init() { + _allowAccess.value = AccessControlManager.getInstance().allowAccess + _enablePwd.value = AccessControlManager.getInstance().enablePwd + _showPwd.value = false + passwd.value = AccessControlManager.getInstance().password + } + + fun setIpAddress(ipAddress: String) { + _ipAddress.value = ipAddress + } + + fun setAllowAccess(allowAccess: Boolean) { + AccessControlManager.getInstance().allowAccess = allowAccess + _allowAccess.value = allowAccess + } + + fun setEnablePwd(enablePwd: Boolean) { + AccessControlManager.getInstance().enablePwd = enablePwd + _enablePwd.value = enablePwd + } + + fun switchPwdVisibility() { + _showPwd.value = !(_showPwd.value ?: false) + } +} diff --git a/app/src/main/java/com/youngfeng/android/assistant/manager/AccessControlManager.kt b/app/src/main/java/com/youngfeng/android/assistant/manager/AccessControlManager.kt new file mode 100644 index 0000000..2ea7c34 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/manager/AccessControlManager.kt @@ -0,0 +1,69 @@ +package com.youngfeng.android.assistant.manager + +import android.content.Context +import com.youngfeng.android.assistant.app.AirControllerApp + +interface AccessControlManager { + var allowAccess: Boolean + var enablePwd: Boolean + var password: String? + + companion object { + private val instance = AccessControlManagerImpl() + + @JvmStatic + fun getInstance() = instance + } +} + +class AccessControlManagerImpl : AccessControlManager { + companion object { + const val KEY_ALLOW_ACCESS = "AccessControlManager.allowAccess" + const val KEY_ENABLE_PWD = "AccessControlManager.enablePwd" + const val KEY_PASSWD = "AccessControlManager.passwd" + } + + override var allowAccess: Boolean + get() { + val pref = AirControllerApp.getInstance() + .getSharedPreferences("AccessControlManager", Context.MODE_PRIVATE) + return pref.getBoolean(KEY_ALLOW_ACCESS, true) + } + set(value) { + val pref = AirControllerApp.getInstance() + .getSharedPreferences("AccessControlManager", Context.MODE_PRIVATE) + val editor = pref.edit() + editor.putBoolean(KEY_ALLOW_ACCESS, value) + editor.apply() + } + + override var enablePwd: Boolean + get() { + val pref = AirControllerApp.getInstance() + .getSharedPreferences("AccessControlManager", Context.MODE_PRIVATE) + return pref.getBoolean(KEY_ENABLE_PWD, false) + } + set(value) { + val pref = AirControllerApp.getInstance() + .getSharedPreferences("AccessControlManager", Context.MODE_PRIVATE) + val editor = pref.edit() + editor.putBoolean(KEY_ENABLE_PWD, value) + editor.apply() + } + + override var password: String? + get() { + val pref = AirControllerApp.getInstance() + .getSharedPreferences("AccessControlManager", Context.MODE_PRIVATE) + return pref.getString(KEY_PASSWD, null) + } + set(value) { + if (value == null) return + + val pref = AirControllerApp.getInstance() + .getSharedPreferences("AccessControlManager", Context.MODE_PRIVATE) + val editor = pref.edit() + editor.putString(KEY_PASSWD, value) + editor.apply() + } +} diff --git a/app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuAdapter.kt b/app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuAdapter.kt new file mode 100644 index 0000000..5ea86d3 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuAdapter.kt @@ -0,0 +1,41 @@ +package com.youngfeng.android.assistant.popmenu + +import android.content.Context +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.skydoves.powermenu.MenuBaseAdapter +import com.youngfeng.android.assistant.R + +class CheckMenuAdapter : MenuBaseAdapter() { + + override fun getView(index: Int, view: View?, viewGroup: ViewGroup?): View { + val context: Context = viewGroup!!.context + + var currentView = view + if (currentView == null) { + val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + currentView = layoutInflater.inflate(R.layout.check_menu_item, viewGroup, false) + } + + currentView?.apply { + val item = getItem(index) as CheckMenuItem + currentView.findViewById(R.id.text_title).text = item.text + + if (item.isChecked) { + currentView.setBackgroundColor(Color.parseColor("#e6f3fe")) + currentView.findViewById(R.id.text_title).setTextColor(Color.parseColor("#0c89ff")) + currentView.findViewById(R.id.image_checked).visibility = View.VISIBLE + } else { + currentView.setBackgroundColor(Color.WHITE) + currentView.findViewById(R.id.text_title).setTextColor(Color.parseColor("#010101")) + currentView.findViewById(R.id.image_checked).visibility = View.GONE + } + } + + return super.getView(index, currentView, viewGroup) + } +} diff --git a/app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuItem.kt b/app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuItem.kt new file mode 100644 index 0000000..8e5d1a7 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/popmenu/CheckMenuItem.kt @@ -0,0 +1,6 @@ +package com.youngfeng.android.assistant.popmenu + +data class CheckMenuItem( + val text: String, + val isChecked: Boolean +) diff --git a/app/src/main/java/com/youngfeng/android/assistant/scan/ScanActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/scan/ScanActivity.kt index feba6a4..6a3642a 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/scan/ScanActivity.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/scan/ScanActivity.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Bundle import android.provider.Settings import android.util.Log +import android.view.MenuItem import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import cn.bingoogolapple.qrcode.core.QRCodeView @@ -40,8 +41,7 @@ class ScanActivity : AppCompatActivity(), QRCodeView.Delegate, EasyPermissions.P super.onCreate(savedInstanceState) setContentView(R.layout.activity_scan) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setHomeButtonEnabled(true) + supportActionBar?.setDisplayHomeAsUpEnabled(true) initZXingView() checkCameraPermission { @@ -92,8 +92,6 @@ class ScanActivity : AppCompatActivity(), QRCodeView.Delegate, EasyPermissions.P } override fun onScanQRCodeSuccess(result: String) { - Log.d(TAG, "onScanQRCodeSuccess: $result") - if (result.startsWith("http://") || result.startsWith("https://")) { openExternalBrowser(result) } @@ -136,8 +134,12 @@ class ScanActivity : AppCompatActivity(), QRCodeView.Delegate, EasyPermissions.P EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults) } - override fun onSupportNavigateUp(): Boolean { - return super.onSupportNavigateUp() + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) } override fun onResume() { diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt index 4bba783..1a7f5ee 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt @@ -170,4 +170,8 @@ class CommonController { EventBus.getDefault().post(BatchUninstallEvent(packages)) return HttpResponseEntity.success() } + + fun connect(): HttpResponseEntity { + return HttpResponseEntity.success() + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt b/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt index 86426f2..48b8667 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt @@ -1,11 +1,14 @@ package com.youngfeng.android.assistant.util import android.content.Context +import android.content.Context.WIFI_SERVICE import android.content.Intent import android.net.Uri +import android.net.wifi.WifiManager import android.os.BatteryManager import android.os.Build import android.os.Environment +import android.text.format.Formatter import androidx.core.content.FileProvider import com.youngfeng.android.assistant.model.StorageSize import com.youngfeng.android.assistant.server.entity.ApkInfo @@ -75,4 +78,10 @@ object CommonUtil { intent.data = Uri.fromParts("package", context.packageName, null) context.startActivity(intent) } + + fun getLocalIpAddress(context: Context): String? { + val wifiManager = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + val wifiInfo = wifiManager.connectionInfo + return Formatter.formatIpAddress(wifiInfo.ipAddress) + } } diff --git a/app/src/main/res/drawable/background_edit_text.xml b/app/src/main/res/drawable/background_edit_text.xml new file mode 100644 index 0000000..1a113a0 --- /dev/null +++ b/app/src/main/res/drawable/background_edit_text.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_round.xml b/app/src/main/res/drawable/background_round.xml new file mode 100644 index 0000000..8ad37ac --- /dev/null +++ b/app/src/main/res/drawable/background_round.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_connection.xml b/app/src/main/res/layout/activity_connection.xml new file mode 100644 index 0000000..db36b65 --- /dev/null +++ b/app/src/main/res/layout/activity_connection.xml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index d25456c..7560622 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -110,6 +110,7 @@ android:layout_marginTop="20dp" android:layout_marginStart="20dp" android:layout_marginEnd="20dp" + app:cardBackgroundColor="@color/white" app:cardElevation ="5dp"> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/select.xml b/app/src/main/res/layout/select.xml new file mode 100644 index 0000000..af83f13 --- /dev/null +++ b/app/src/main/res/layout/select.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml index 29bc5da..0b279a9 100644 --- a/app/src/main/res/menu/main.xml +++ b/app/src/main/res/menu/main.xml @@ -12,6 +12,10 @@ android:id="@+id/menu_scan" android:title="@string/scan" android:icon="@mipmap/ic_scan" /> + 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_connect.png b/app/src/main/res/mipmap-xxhdpi/ic_connect.png new file mode 100644 index 0000000000000000000000000000000000000000..279e67396e0b74152e027397010ff8cc3d34c136 GIT binary patch literal 1602 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_up_down.png b/app/src/main/res/mipmap-xxhdpi/ic_up_down.png new file mode 100644 index 0000000000000000000000000000000000000000..1dc94c7ff46cf7d82f394dcfdb65e168ca6695ae GIT binary patch literal 744 zcmVP)Px%qDe$SRCr$PnJ=#sK@h-qPSp2f03QLlyB~m(*93vUAP@)y0>gU(fj}S-&ma(l zye0@lo80Y%CKv=H7(kGKz&wFKU`R`cEV*l%%k|HDw=;9)w)$>%e!rdBo8E!y_zybH zucClh0Ub9GTcG0w5D&rF0e!o8$jYfa0 zwFeRs*aTGJ5pmM%^{z}Nli%geOGsc5P=yD8Y&M$>rqk)KYOixkAQw!p;16vc0wwTSOHSz+p=z~fVS*}R35Pcq|CQv-BCancel Expand Scan + Connect About Scanning QR code needs enable camera permission, please go to setting to enable it. Enable immediately @@ -76,4 +77,13 @@ Upload audios failure Upload videos failure Working -_- + IP + Access Control + Security + allow + disallow + none + password + Password must contain at least 6 digits + Set web connection \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index a877c37..93555f0 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,17 @@ - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8fde14..5773141 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,7 @@ 取消 展开 扫一扫 + 连接 关于 扫描二维码需要开启摄像头权限,请前往设置页面开启 立即开启 @@ -76,4 +77,13 @@ 上传音频失败 上传视频失败 工作中 -_- + 地址 + 访问控制 + 安全性 + 允许 + 禁止 + + 密码 + 密码应至少包含6位数字 + 设置网页连接 \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e5adad3..4bf2f0b 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,6 +1,6 @@ - \ No newline at end of file From acf093ba1945d1f0868d0570f9e050e2db58fb55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Wed, 23 Nov 2022 13:12:01 +0800 Subject: [PATCH 03/11] Add API "connect" to check web connection validity. --- .../android/assistant/server/HttpConst.kt | 2 ++ .../server/controller/CommonController.kt | 33 +++++++++++++++---- .../server/request/ConnectionRequest.kt | 5 +++ app/src/main/res/values-en/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 5 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt b/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt index 297817b..68b05c0 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt @@ -64,6 +64,8 @@ enum class HttpError(var code: String, var value: Int) { // Common module UploadInstallFileFailure("01", R.string.install_bundle_upload_failure), InstallationFileNotFound("02", R.string.installation_package_not_found), + WebAccessIsNotAllowed("03", R.string.web_access_is_not_allowed), + PasswdIsInCorrect("04", R.string.web_passwd_is_not_correct), // System module, process common error LackOfNecessaryPermissions("01", R.string.lack_of_necessary_permissions); diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt index 1a7f5ee..591dccc 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt @@ -17,11 +17,13 @@ import com.youngfeng.android.assistant.app.AirControllerApp import com.youngfeng.android.assistant.db.RoomDatabaseHolder import com.youngfeng.android.assistant.db.entity.UploadFileRecord import com.youngfeng.android.assistant.event.BatchUninstallEvent +import com.youngfeng.android.assistant.manager.AccessControlManager import com.youngfeng.android.assistant.model.MobileInfo import com.youngfeng.android.assistant.server.HttpError import com.youngfeng.android.assistant.server.HttpModule import com.youngfeng.android.assistant.server.entity.HttpResponseEntity import com.youngfeng.android.assistant.server.entity.InstalledAppEntity +import com.youngfeng.android.assistant.server.request.ConnectionRequest import com.youngfeng.android.assistant.server.util.ErrorBuilder import com.youngfeng.android.assistant.util.CommonUtil import com.youngfeng.android.assistant.util.MD5Helper @@ -60,8 +62,7 @@ class CommonController { index++ val appName = packageManager.getApplicationLabel( packageManager.getApplicationInfo( - it.packageName, - 0 + it.packageName, 0 ) ).toString() val packageInfo = packageManager.getPackageInfo(it.packageName, 0) @@ -127,10 +128,13 @@ class CommonController { } catch (e: Exception) { Timber.e("Upload install bundle failure, reason: ${e.message}") val languageCode = httpRequest.getHeader("languageCode") - val locale = if (!TextUtils.isEmpty(languageCode)) Locale(languageCode!!) else Locale("en") + val locale = + if (!TextUtils.isEmpty(languageCode)) Locale(languageCode!!) else Locale("en") - val response = ErrorBuilder().locale(locale).module(HttpModule.CommonModule).error(HttpError.UploadInstallFileFailure).build() - response.msg = mContext.getString(HttpError.UploadInstallFileFailure.value).replace("%s", "${e.message}") + val response = ErrorBuilder().locale(locale).module(HttpModule.CommonModule) + .error(HttpError.UploadInstallFileFailure).build() + response.msg = mContext.getString(HttpError.UploadInstallFileFailure.value) + .replace("%s", "${e.message}") return response } } @@ -171,7 +175,22 @@ class CommonController { return HttpResponseEntity.success() } - fun connect(): HttpResponseEntity { - return HttpResponseEntity.success() + @ResponseBody + @PostMapping("/connect") + fun connect(@RequestBody reqEntity: ConnectionRequest): HttpResponseEntity { + if (AccessControlManager.getInstance().allowAccess) { + val enablePwd = AccessControlManager.getInstance().enablePwd + if (enablePwd) { + val passwd = AccessControlManager.getInstance().password + if (reqEntity.passwd != passwd) { + return ErrorBuilder().module(HttpModule.CommonModule) + .error(HttpError.PasswdIsInCorrect).build() + } + } + return HttpResponseEntity.success() + } else { + return ErrorBuilder().module(HttpModule.CommonModule) + .error(HttpError.WebAccessIsNotAllowed).build() + } } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt b/app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt new file mode 100644 index 0000000..a572ff1 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt @@ -0,0 +1,5 @@ +package com.youngfeng.android.assistant.server.request + +data class ConnectionRequest( + val passwd: String +) \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 611a787..6445ab6 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -86,4 +86,6 @@ password Password must contain at least 6 digits Set web connection + Webpage access is prohibited, please set "Allow" in "Setting Webpage Connection-Access Control" on the mobile phone + Password is incorrect, please re-enter \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5773141..d278c07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,4 +86,6 @@ 密码 密码应至少包含6位数字 设置网页连接 + 网页访问是禁止的,请在手机端"设置网页连接-访问控制"设置"允许" + 密码不正确,请重新输入 \ No newline at end of file From 6eb43704fc63351153f833385602c6d5f726b2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Wed, 23 Nov 2022 13:13:48 +0800 Subject: [PATCH 04/11] Adapt "NewLineOfNewFile" lint rule. --- .../android/assistant/server/request/ConnectionRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt b/app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt index a572ff1..671e08c 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/request/ConnectionRequest.kt @@ -2,4 +2,4 @@ package com.youngfeng.android.assistant.server.request data class ConnectionRequest( val passwd: String -) \ No newline at end of file +) From 7ace32f4ca0b54f4332ee20a1573ec7033ab1d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Fri, 25 Nov 2022 12:42:02 +0800 Subject: [PATCH 05/11] Add "DrawOverlay" permission to show uninstall apps activity when the app runs in background. --- app/src/main/AndroidManifest.xml | 2 + .../android/assistant/MainActivity.kt | 41 +++++++++++++++- .../event/RequestDrawOverlayEvent.kt | 3 ++ .../server/controller/CommonController.kt | 4 ++ .../android/assistant/util/CommonUtil.kt | 47 ++++++++++++++----- app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/youngfeng/android/assistant/event/RequestDrawOverlayEvent.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad4fab4..45bd02b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,8 @@ + + + ) { dialog, _ -> CommonUtil.openExternalBrowser( this, getString(R.string.url_project_desktop) ) + dialog.dismiss() } .setNegativeButton(R.string.refuse) { dialog, _ -> dialog.dismiss() }.setMessage(R.string.tip_support_developer) .create() } + private val mRequestDrawOverlayDialog by lazy { + AlertDialog.Builder(this) + .setPositiveButton( + R.string.authorize_now + ) { _, _ -> + startDrawOverlayRequestActivity() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> + dialog.dismiss() + }.setMessage(R.string.rationale_draw_overlay_window) + .create() + } private val mUninstalledPackages = mutableListOf() private lateinit var mUninstallLauncher: ActivityResultLauncher private val mPermissionManager by lazy { @@ -86,6 +100,7 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { private const val RC_PERM_GET_ACCOUNTS = 3 private const val RC_PERM_READ_CONTACTS = 4 private const val RC_PERM_WRITE_CONTACTS = 5 + private const val RC_DIALOG_PERMISSION = 6 } @SuppressLint("ClickableViewAccessibility") @@ -100,6 +115,7 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { updatePermissionsStatus() requestPermissions(true) + requestFloatPermission() if (!EventBus.getDefault().isRegistered(this)) { EventBus.getDefault().register(this) @@ -274,6 +290,24 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { ) } + private fun requestFloatPermission() { + if (!CommonUtil.checkFloatPermission(this)) { + mRequestDrawOverlayDialog.show() + } + } + + private fun startDrawOverlayRequestActivity() { + val sdkInt = Build.VERSION.SDK_INT + if (sdkInt >= Build.VERSION_CODES.O) { + val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + startActivity(intent) + } else if (sdkInt >= Build.VERSION_CODES.M) { + val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + } + } + @Subscribe(threadMode = ThreadMode.MAIN) fun onDeviceConnected(event: DeviceConnectEvent) { mViewModel.setDeviceConnected(true) @@ -325,6 +359,11 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { mPermissionManager.requestMultiplePermissions(RC_PERMISSIONS, *permissions) } + @Subscribe(threadMode = ThreadMode.MAIN) + fun onRequestDrawOverlay(event: RequestDrawOverlayEvent) { + requestFloatPermission() + } + private fun batchUninstall(packageName: String) { val intent = Intent() intent.action = Intent.ACTION_DELETE diff --git a/app/src/main/java/com/youngfeng/android/assistant/event/RequestDrawOverlayEvent.kt b/app/src/main/java/com/youngfeng/android/assistant/event/RequestDrawOverlayEvent.kt new file mode 100644 index 0000000..fba35a0 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/event/RequestDrawOverlayEvent.kt @@ -0,0 +1,3 @@ +package com.youngfeng.android.assistant.event + +class RequestDrawOverlayEvent() diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt index 591dccc..46dd532 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt @@ -17,6 +17,7 @@ import com.youngfeng.android.assistant.app.AirControllerApp import com.youngfeng.android.assistant.db.RoomDatabaseHolder import com.youngfeng.android.assistant.db.entity.UploadFileRecord import com.youngfeng.android.assistant.event.BatchUninstallEvent +import com.youngfeng.android.assistant.event.RequestDrawOverlayEvent import com.youngfeng.android.assistant.manager.AccessControlManager import com.youngfeng.android.assistant.model.MobileInfo import com.youngfeng.android.assistant.server.HttpError @@ -171,6 +172,9 @@ class CommonController { @ResponseBody @PostMapping("/uninstall") fun unInstall(@RequestBody packages: List): HttpResponseEntity { + if (!CommonUtil.checkFloatPermission(mContext)) { + EventBus.getDefault().post(RequestDrawOverlayEvent()) + } EventBus.getDefault().post(BatchUninstallEvent(packages)) return HttpResponseEntity.success() } diff --git a/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt b/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt index 48b8667..b1adde3 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt @@ -1,18 +1,19 @@ package com.youngfeng.android.assistant.util import android.content.Context -import android.content.Context.WIFI_SERVICE import android.content.Intent import android.net.Uri -import android.net.wifi.WifiManager import android.os.BatteryManager +import android.os.Binder import android.os.Build import android.os.Environment -import android.text.format.Formatter +import android.provider.Settings import androidx.core.content.FileProvider import com.youngfeng.android.assistant.model.StorageSize import com.youngfeng.android.assistant.server.entity.ApkInfo import java.io.File +import java.lang.reflect.Field +import java.lang.reflect.Method object CommonUtil { @@ -35,9 +36,7 @@ object CommonUtil { fun install(context: Context, apkFile: File) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val uri = FileProvider.getUriForFile( - context, - context.applicationContext.packageName.toString() + ".provider", - apkFile + context, context.applicationContext.packageName.toString() + ".provider", apkFile ) val intent = Intent(Intent.ACTION_VIEW) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) @@ -47,8 +46,7 @@ object CommonUtil { } else { val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType( - Uri.fromFile(apkFile), - "application/vnd.android.package-archive" + Uri.fromFile(apkFile), "application/vnd.android.package-archive" ) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(intent) @@ -66,7 +64,9 @@ object CommonUtil { val applicationInfo = packageManager.getApplicationInfo(packageName, 0) val file = File(applicationInfo.publicSourceDir) - val appName = packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() + val appName = + packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)) + .toString() return ApkInfo(packageName = packageName, localizeName = appName, file = file) } @@ -79,9 +79,30 @@ object CommonUtil { context.startActivity(intent) } - fun getLocalIpAddress(context: Context): String? { - val wifiManager = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager - val wifiInfo = wifiManager.connectionInfo - return Formatter.formatIpAddress(wifiInfo.ipAddress) + fun checkFloatPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(context) + } else { + try { + var cls = Class.forName("android.content.Context") + val declaredField: Field = cls.getDeclaredField("APP_OPS_SERVICE") + declaredField.isAccessible = true + var obj: Any? = declaredField.get(cls) as? String ?: return false + val str2 = obj as String + obj = cls.getMethod("getSystemService", String::class.java).invoke(context, str2) + cls = Class.forName("android.app.AppOpsManager") + val declaredField2: Field = cls.getDeclaredField("MODE_ALLOWED") + declaredField2.isAccessible = true + val checkOp: Method = cls.getMethod( + "checkOp", Integer.TYPE, Integer.TYPE, String::class.java + ) + val result = + checkOp.invoke(obj, 24, Binder.getCallingUid(), context.packageName) as Int + result == declaredField2.getInt(cls) + } catch (e: Exception) { + e.printStackTrace() + false + } + } } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 6445ab6..4d9dad8 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -88,4 +88,5 @@ Set web connection Webpage access is prohibited, please set "Allow" in "Setting Webpage Connection-Access Control" on the mobile phone Password is incorrect, please re-enter + In order to serve you better, please allow AirController to be displayed on top of other applications \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d278c07..5e31dae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,4 +88,5 @@ 设置网页连接 网页访问是禁止的,请在手机端"设置网页连接-访问控制"设置"允许" 密码不正确,请重新输入 + 为了更好地给你提供服务,请允许AirController显示在其它应用的上层 \ No newline at end of file From 71b524c65b25feb513966db95e33627598f7c79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Sat, 26 Nov 2022 20:42:56 +0800 Subject: [PATCH 06/11] Add developer support activity. --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 17 ++- app/src/main/assets/images/alipay.png | Bin 0 -> 9085 bytes app/src/main/assets/images/wechat_pay.png | Bin 0 -> 10491 bytes .../youngfeng/android/assistant/Constants.kt | 3 + .../android/assistant/MainActivity.kt | 21 +--- .../support/DeveloperSupportActivity.kt | 44 +++++++ .../android/assistant/view/RewardDialog.kt | 62 ++++++++++ .../main/res/drawable/background_qr_code.xml | 7 ++ .../res/drawable/background_save_to_local.xml | 9 ++ app/src/main/res/drawable/background_star.xml | 6 + .../main/res/drawable/background_star_1.xml | 12 ++ .../main/res/drawable/background_watch_ad.xml | 6 + app/src/main/res/drawable/selector_reward.xml | 16 +++ .../res/layout/activity_developer_support.xml | 111 ++++++++++++++++++ app/src/main/res/layout/dialog_reward.xml | 61 ++++++++++ app/src/main/res/mipmap-xxhdpi/ic_play.png | Bin 0 -> 1345 bytes app/src/main/res/mipmap-xxhdpi/ic_star.png | Bin 0 -> 1835 bytes app/src/main/res/values-en/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 20 files changed, 366 insertions(+), 23 deletions(-) create mode 100644 app/src/main/assets/images/alipay.png create mode 100644 app/src/main/assets/images/wechat_pay.png create mode 100644 app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt create mode 100644 app/src/main/java/com/youngfeng/android/assistant/view/RewardDialog.kt create mode 100644 app/src/main/res/drawable/background_qr_code.xml create mode 100644 app/src/main/res/drawable/background_save_to_local.xml create mode 100644 app/src/main/res/drawable/background_star.xml create mode 100644 app/src/main/res/drawable/background_star_1.xml create mode 100644 app/src/main/res/drawable/background_watch_ad.xml create mode 100644 app/src/main/res/drawable/selector_reward.xml create mode 100644 app/src/main/res/layout/activity_developer_support.xml create mode 100644 app/src/main/res/layout/dialog_reward.xml create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_play.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_star.png diff --git a/app/build.gradle b/app/build.gradle index d502623..fc817cf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -114,4 +114,6 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' implementation 'com.github.vestrel00:contacts-android:0.2.2' implementation "org.jetbrains.kotlin:kotlin-reflect:1.6.21" + implementation 'com.github.bumptech.glide:glide:4.14.2' + annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 45bd02b..dad38bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,9 +11,10 @@ - - + @@ -30,14 +31,18 @@ android:hardwareAccelerated="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:requestLegacyExternalStorage="true" android:supportsRtl="true" - android:largeHeap="true" android:theme="@style/Theme.AndroidMobileAssistant"> + + android:label="@string/about" /> + android:theme="@style/Theme.AppCompat.Light.NoActionBar" /> - + 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 S+`;e<0V)a_^3}3dq5lsye8tZI literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/youngfeng/android/assistant/Constants.kt b/app/src/main/java/com/youngfeng/android/assistant/Constants.kt index 795c3df..b924958 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/Constants.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/Constants.kt @@ -166,4 +166,7 @@ object Constants { const val ROOT_DIR_TYPE_SDCARD = 1 const val ROOT_DIR_TYPE_DOWNLOAD = 2 + + const val URL_GITHUB_STAR = "https://img.shields.io/github/stars/air-controller/air-controller-desktop?style=social" + const val URL_GITHUB = "https://github.com/air-controller/air-controller-desktop" } diff --git a/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt index 008e2ee..b68045c 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/MainActivity.kt @@ -38,6 +38,7 @@ import com.youngfeng.android.assistant.model.DesktopInfo import com.youngfeng.android.assistant.model.Device import com.youngfeng.android.assistant.scan.ScanActivity import com.youngfeng.android.assistant.service.NetworkService +import com.youngfeng.android.assistant.support.DeveloperSupportActivity import com.youngfeng.android.assistant.util.CommonUtil import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -59,22 +60,6 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { }.setMessage(R.string.tip_disconnect) .create() } - private val mSupportDeveloperDialog by lazy { - AlertDialog.Builder(this) - .setPositiveButton( - R.string.support - ) { dialog, _ -> - CommonUtil.openExternalBrowser( - this, - getString(R.string.url_project_desktop) - ) - dialog.dismiss() - } - .setNegativeButton(R.string.refuse) { dialog, _ -> - dialog.dismiss() - }.setMessage(R.string.tip_support_developer) - .create() - } private val mRequestDrawOverlayDialog by lazy { AlertDialog.Builder(this) .setPositiveButton( @@ -157,7 +142,8 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { } this.textSupportDeveloper.setOnClickListener { - if (!mSupportDeveloperDialog.isShowing) mSupportDeveloperDialog.show() + val intent = Intent(this@MainActivity, DeveloperSupportActivity::class.java) + startActivity(intent) } this.textAuthorizeNow.apply { @@ -252,6 +238,7 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { Manifest.permission.GET_ACCOUNTS, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS, + Manifest.permission.REQUEST_INSTALL_PACKAGES ) EasyPermissions.hasPermissions(this, *permissions.toTypedArray()).apply { diff --git a/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt new file mode 100644 index 0000000..ce84a4b --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt @@ -0,0 +1,44 @@ +package com.youngfeng.android.assistant.support + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.LinearLayoutCompat +import com.youngfeng.android.assistant.Constants +import com.youngfeng.android.assistant.R +import com.youngfeng.android.assistant.view.RewardDialog + +class DeveloperSupportActivity : AppCompatActivity() { + private val mStarBtn by lazy { findViewById(R.id.btn_star) } + private val mWatchAd by lazy { findViewById(R.id.btn_watch_ad) } + private val mRewardBtn by lazy { findViewById(R.id.btn_reward) } + + private val mRewardDialog by lazy { RewardDialog(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_developer_support) + + initView() + initListener() + } + + private fun initView() { + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + private fun initListener() { + mStarBtn.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(Constants.URL_GITHUB) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } + mWatchAd.setOnClickListener { } + mRewardBtn.setOnClickListener { + mRewardDialog.show() + } + } +} diff --git a/app/src/main/java/com/youngfeng/android/assistant/view/RewardDialog.kt b/app/src/main/java/com/youngfeng/android/assistant/view/RewardDialog.kt new file mode 100644 index 0000000..a7717ad --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/view/RewardDialog.kt @@ -0,0 +1,62 @@ +package com.youngfeng.android.assistant.view + +import android.app.Dialog +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.widget.AppCompatButton +import com.bumptech.glide.Glide +import com.youngfeng.android.assistant.R +import com.youngfeng.android.assistant.util.PathHelper +import java.io.File + +class RewardDialog(context: Context) : Dialog(context) { + private val mAlipayImage by lazy { findViewById(R.id.image_alipay) } + private val mWechatPayImage by lazy { findViewById(R.id.image_wechat_pay) } + private val mAlipayBtn by lazy { findViewById(R.id.btn_alipay) } + private val mWechatPayBtn by lazy { findViewById(R.id.btn_wechat_pay) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.dialog_reward) + + initView() + initListener() + } + + private fun initView() { + Glide.with(context).load(Uri.parse("file:///android_asset/images/alipay.png")).into(mAlipayImage) + Glide.with(context).load(Uri.parse("file:///android_asset/images/wechat_pay.png")).into(mWechatPayImage) + } + + private fun initListener() { + mAlipayBtn.setOnClickListener { + saveAssetImageToGallery("images/alipay.png", "Alipay.png") + } + mWechatPayBtn.setOnClickListener { + saveAssetImageToGallery("images/wechat_pay.png", "WechatPay.png") + } + } + + private fun saveAssetImageToGallery(path: String, fileName: String) { + context.assets.open(path).use { inputStream -> + val file = File(PathHelper.photoRootDir(), fileName) + file.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + val values = ContentValues() + + values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) + values.put(MediaStore.Images.Media.MIME_TYPE, "image/png") + values.put(MediaStore.MediaColumns.DATA, file.absolutePath) + + context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + + Toast.makeText(context, R.string.qr_code_saved, Toast.LENGTH_SHORT).show() + } + } +} diff --git a/app/src/main/res/drawable/background_qr_code.xml b/app/src/main/res/drawable/background_qr_code.xml new file mode 100644 index 0000000..1b8ff21 --- /dev/null +++ b/app/src/main/res/drawable/background_qr_code.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_save_to_local.xml b/app/src/main/res/drawable/background_save_to_local.xml new file mode 100644 index 0000000..6c29b22 --- /dev/null +++ b/app/src/main/res/drawable/background_save_to_local.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_star.xml b/app/src/main/res/drawable/background_star.xml new file mode 100644 index 0000000..41dcc83 --- /dev/null +++ b/app/src/main/res/drawable/background_star.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_star_1.xml b/app/src/main/res/drawable/background_star_1.xml new file mode 100644 index 0000000..7f856d4 --- /dev/null +++ b/app/src/main/res/drawable/background_star_1.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/background_watch_ad.xml b/app/src/main/res/drawable/background_watch_ad.xml new file mode 100644 index 0000000..5119646 --- /dev/null +++ b/app/src/main/res/drawable/background_watch_ad.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_reward.xml b/app/src/main/res/drawable/selector_reward.xml new file mode 100644 index 0000000..7aefacc --- /dev/null +++ b/app/src/main/res/drawable/selector_reward.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_developer_support.xml b/app/src/main/res/layout/activity_developer_support.xml new file mode 100644 index 0000000..3ea49d7 --- /dev/null +++ b/app/src/main/res/layout/activity_developer_support.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_reward.xml b/app/src/main/res/layout/dialog_reward.xml new file mode 100644 index 0000000..d5ce7f2 --- /dev/null +++ b/app/src/main/res/layout/dialog_reward.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-xxhdpi/ic_play.png b/app/src/main/res/mipmap-xxhdpi/ic_play.png new file mode 100644 index 0000000000000000000000000000000000000000..31699669cab7cf62055d7b12fcf22a02a04000b8 GIT binary patch literal 1345 zcmV-H1-|-;P)Px(`$(EEpEWf5C&-DN&SY zGSj_TWctS}5m6$t5|u1!kobdOP{bgLvM8d7h@yzo{iHY3$r>g*U0rWxs(Ze3e(!bF z=QGva(^Flg2p_KqD6d+B#t?*f_9cK22=R=7XU)L~fe_CKc-9<@kTwfBdi3}`TI&ay z=@B9xYQ3~|;6VOC5Cp$_)^Bf{BQ`>g9b0>hna+&q=?_YgiK5_TZz*`xFcBg9$k^M} zbIi&lN$)+6`tMy+M1-uYtX(Fe`>OR$y*M1+VKz5k`mtL4VnsO%IdbI5yS2`)Sz`N6 zDP^Ll*R{-ReYmrbm6hX%i1hiIq_&816UXb@HS=4SHg6##pz8T7%gkV~{&VXHTXx73 zA-k>qhM7&0grQ|0S{3e-kP_^hqub;UTSdBJXM7Y=HvQQwBQqETzgFx^E5dvhQULyX zbQ;@e1?>u*@LfpRg*UWTrr+;O8cltl#y3gGZa0*o zFN;Soaop>-QA*>4?2+(Vo@bhN#Vm@D(!r;rC&{n+DrM1xl)v^P zB5k6m`-N{}7E;JaLNQm&?Ce}I+uPeiU&Ji3kkY|LX5C4W?(05CfspcEjMXbzXC{i) zznZ5Q2-!oQnXVPTUE(Lz(}jLWl@Sbu!O~CQVO&?^+H*TnXOH-x<9jcEY73 zgt*Wflgmu+66t4>q=);SQaA$6#Y&%vV;IhL-K@Nm(Uph!L*zl&fLDS|(! zL@>$}!C$yC9G$CRl7llskph?oB!W?<2qwzOaCEMMsie*bB?@4&wFE_>8jRNwgh;xV z!3#U?3`Hv7l{OKK0!5S}y!0u+QKAH1inZ#9#TXoU;%tf_dvXW{yIzOzgN5{;Y*yA>|2|-5CnOQsY%_ zfo3EGi}6>HQN&DH2)3)JDq;qAS=i9UR>qV@2(~<`BVr081e<~t>vFrukQ*V`Hm~$B zZz0%N@|F?dEM#?c^{(OYPTMVru=VO~PEF22uAjEnE)miF)lPrdoOnM@B0{izceSr# zl_(J*#euNFeHGl3W5k3MhdO-tiF>uq4k@J{R*EuadWTt`Yx6dDlM&lRhzhYgG@{25 zfDq5V7Z3s=o)Pe@IT#@j;u!(Ynu8GnA)XQNtU3Pygm#*P5wtO200000NkvXXu0mjf Da{+nJ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_star.png b/app/src/main/res/mipmap-xxhdpi/ic_star.png new file mode 100644 index 0000000000000000000000000000000000000000..21fea0499af4d6eaacfd538e90c42a094bf5d626 GIT binary patch literal 1835 zcmV+`2h{k9P)Px*EI5ohSx&7mA`N zilV5Ph}Z>!!Sit!*7w`pJ9m24IsfjQGv}P=&dj}YVkc24D-TS{N}H+-6_Csxf(l3l zBr^|WrcPZU6_Ct4keNDlg;YQ?^FU_mjMEC40r)L5|8`bqTmab{m2QXcq<*NeP z!iuEQ0nrw9*96aB2k4M5l1c|eTh#gkJU=V&eTj&a49Gmd=R=%+8gP0AB(4X}08SY) z9Lg2w+ z1KI!)-(tWsz?_K;7?){(1~vs=8fB0PAmX1McoJA`T)Y$6j!zmebjdlAOabW$xfR%@ zO=$i7KOT4k*aZ02#4lX|k<2rJlTC!zF+AR^jK55MVFQR{E9Xh~OO=Lu;C7qc#1;^# zu>nke0xC`Efy;nHY#iePh~`_WNm!fLX7VxRQHtxSwzjzfqIqWql(?4Vm#?j3Ecp%x zF16&H%;p1--uyQJd%7r7IPdPjEv`2B1Vp;148VH1x*C&+|5soKgIr@H^?U=ON%sS; zGMc^oL7z3Ls;~JnB?KVSZ8P8j&s(A|n|;Z7s{!L9UjlufArtjl5x5&z$fv@Y$a)&s z4*0>hKA~1fPfuM)-VChgTk(YQf@ai*2BhaV#l#gGFP*Li_77=9EI?$#If-K{u6{fh zIMG!|8-Gkdq|2eMA5Lus1^WHCjx{OPYQzRaif#)ii+vhvt>ZH816&(x$`pXew$+@#lmf3|bH6XkCxFL0Mz!2U$ft>=#Z55ds5JemndCZZL z_$JAuecsP;yXvW@H^En8KPf2zQPSAMu?_ApU+`;^LAs;_M4jMrZhP@3Fl`3_y$nqW z$S(yH^e^YBw$T`@RDfuI9+~56Y(dz8>Zzi8tZ0-95QTUZ>dl>${yAGrB~t;S(&YJL z8tO3D(!je8{GC`+0Mg%yx|6br1}l&G>{xTB07QYF<2@Uw;FmJ<6<$?klu!E5v4cb> zT~Yv|^n4{i_#idH+yZ#HPuN+&Nj`-4$i2i zkb29j05v$p21NCW+Fo2qnSx3&YNHE)quT}<{Qf$yiorPNA+Z4&)*9T@kf#9En5zN5 zDX9!kt3|yRU1$-&Yf{`GWnu!Ne=txtQQKJhk-)_c9#vMlk}PaV^&YDG2ud*lQ9jd^ z7SnzZyNAICK)IpTy=n5I+m)`)P$4^94qZ~s-}3=w9rVE zVq2RSVLK)kAUX`&I=Lx&s2IDR+SsJHjK1`zHkepkd6pda_C-UETD~x*waki@ukMWTsACAr+9!Jdl|>b%kUG Z$iMhe+GS2fi>Lqq002ovPDHLkV1j!bOUeKM literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4d9dad8..79ef0a5 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -89,4 +89,10 @@ Webpage access is prohibited, please set "Allow" in "Setting Webpage Connection-Access Control" on the mobile phone Password is incorrect, please re-enter In order to serve you better, please allow AirController to be displayed on top of other applications + Star + Choose a way to support the author + Give a reward + The QR code has been saved to the photo album + Reward any amount, support the author to continue to develop, thank you for your support! + Watch Ads \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e31dae..8f9896b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,4 +89,10 @@ 网页访问是禁止的,请在手机端"设置网页连接-访问控制"设置"允许" 密码不正确,请重新输入 为了更好地给你提供服务,请允许AirController显示在其它应用的上层 + Star + 选择一种方式支持作者 + 打赏 + 二维码已保存到相册 + 打赏任意金额,支持作者继续开发,谢谢您的支持! + 看广告 \ No newline at end of file From 2aa9d9b1a4a803bb549dbfb8435cee0baed23913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Sat, 26 Nov 2022 20:51:13 +0800 Subject: [PATCH 07/11] Hide not fully granted prompt. --- app/src/main/res/layout/activity_main.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 7560622..8ae9044 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -78,7 +78,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" - android:visibility="@{viewModel.isAllPermissionsGranted() ? View.GONE : View.VISIBLE}"> + android:visibility="gone"> Date: Tue, 29 Nov 2022 13:34:50 +0800 Subject: [PATCH 08/11] Re-implement delete and download interface, fix known problems --- .../android/assistant/ext/FileExt.kt | 17 ++ .../android/assistant/server/HttpConst.kt | 7 + .../server/controller/AudioController.kt | 132 ++++++--- .../server/controller/FileController.kt | 64 +--- .../server/controller/ImageController.kt | 277 +++++++++++------- .../server/controller/VideoController.kt | 226 +++++++++++--- .../assistant/server/entity/DeleteResult.kt | 18 ++ .../server/request/DeleteMultiFileRequest.kt | 2 +- .../assistant/server/request/IdsRequest.kt | 3 + .../android/assistant/util/AudioUtil.kt | 42 +++ .../android/assistant/util/CommonUtil.kt | 80 +++++ .../android/assistant/util/PhotoUtil.kt | 193 ++++++++++++ .../android/assistant/util/VideoUtil.kt | 143 +++++++++ app/src/main/res/values-en/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + 15 files changed, 975 insertions(+), 243 deletions(-) create mode 100644 app/src/main/java/com/youngfeng/android/assistant/server/entity/DeleteResult.kt create mode 100644 app/src/main/java/com/youngfeng/android/assistant/server/request/IdsRequest.kt diff --git a/app/src/main/java/com/youngfeng/android/assistant/ext/FileExt.kt b/app/src/main/java/com/youngfeng/android/assistant/ext/FileExt.kt index 0ef3cf1..45c6fe3 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/ext/FileExt.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/ext/FileExt.kt @@ -27,3 +27,20 @@ val File.isDoc: Boolean get() { return Constants.DOCUMENT_SUFFIX.contains(this.extension.lowercase()) } + +fun File.deleteRecursivelyWhere(where: (File) -> Boolean) { + if (this.isDirectory) { + this.listFiles()?.forEach { + it.deleteRecursivelyWhere(where) + } + } + + if (where(this)) { + this.delete() + } +} + +val File.isHiddenFile: Boolean + get() { + return this.name.startsWith(".") + } diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt b/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt index 68b05c0..afdf2a1 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt @@ -33,6 +33,7 @@ enum class HttpError(var code: String, var value: Int) { DeleteFileFail("08", R.string.delete_file_fail), RenameFileFail("09", R.string.rename_file_fail), MoveFileFail("10", R.string.move_file_fail), + DeleteFilePartialFailure("11", R.string.delete_file_partial_failure), // 图片模块 DeleteImageFail("01", R.string.delete_image_fail), @@ -40,14 +41,20 @@ enum class HttpError(var code: String, var value: Int) { ImageFileNotExist("03", R.string.image_not_exist), DeleteAlbumFail("04", R.string.delete_album_fail), GetPhotoDirFailure("05", R.string.get_photo_dir_failure), + DeleteImagePartialFailure("06", R.string.delete_image_partial_failure), + DeleteAlbumPartialFailure("07", R.string.delete_album_partial_failure), // 音频模块 DeleteAudioFail("01", R.string.delete_audio_file_fail), UploadAudioFail("02", R.string.upload_audio_file_fail), + DeleteAudioPartialFailure("03", R.string.delete_audio_partial_failure), // 视频模块 DeleteVideoFail("01", R.string.delete_video_file_fail), UploadVideoFailure("02", R.string.upload_video_file_failure), + DeleteVideoPartialFailure("03", R.string.delete_video_partial_failure), + DeleteVideoFolderPartialFailure("04", R.string.delete_video_folder_partial_failure), + DeleteVideoFolderFail("05", R.string.delete_video_folder_fail), // 下载模块 GetDownloadDirFail("01", R.string.get_download_dir_fail), diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt index 77a84f4..76f08d4 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt @@ -2,11 +2,13 @@ package com.youngfeng.android.assistant.server.controller import android.Manifest import android.media.MediaScannerConnection -import android.text.TextUtils +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.yanzhenjie.andserver.annotation.CrossOrigin import com.yanzhenjie.andserver.annotation.GetMapping import com.yanzhenjie.andserver.annotation.PathVariable import com.yanzhenjie.andserver.annotation.PostMapping +import com.yanzhenjie.andserver.annotation.QueryParam import com.yanzhenjie.andserver.annotation.RequestBody import com.yanzhenjie.andserver.annotation.RequestMapping import com.yanzhenjie.andserver.annotation.RequestParam @@ -16,30 +18,34 @@ import com.yanzhenjie.andserver.http.HttpRequest import com.yanzhenjie.andserver.http.HttpResponse import com.yanzhenjie.andserver.http.multipart.MultipartFile import com.yanzhenjie.andserver.util.MediaType -import com.youngfeng.android.assistant.R import com.youngfeng.android.assistant.app.AirControllerApp +import com.youngfeng.android.assistant.db.RoomDatabaseHolder +import com.youngfeng.android.assistant.db.entity.ZipFileRecord import com.youngfeng.android.assistant.event.Permission import com.youngfeng.android.assistant.event.RequestPermissionsEvent -import com.youngfeng.android.assistant.ext.getString import com.youngfeng.android.assistant.server.HttpError import com.youngfeng.android.assistant.server.HttpModule import com.youngfeng.android.assistant.server.entity.AudioEntity +import com.youngfeng.android.assistant.server.entity.DeleteResult import com.youngfeng.android.assistant.server.entity.HttpResponseEntity -import com.youngfeng.android.assistant.server.request.DeleteAudioRequest +import com.youngfeng.android.assistant.server.request.IdsRequest import com.youngfeng.android.assistant.server.response.RangeSupportResponseBody import com.youngfeng.android.assistant.server.util.ErrorBuilder import com.youngfeng.android.assistant.util.AudioUtil +import com.youngfeng.android.assistant.util.CommonUtil +import com.youngfeng.android.assistant.util.MD5Helper import com.youngfeng.android.assistant.util.PathHelper +import net.lingala.zip4j.ZipFile import org.greenrobot.eventbus.EventBus import pub.devrel.easypermissions.EasyPermissions import java.io.File -import java.util.Locale @CrossOrigin @RestController @RequestMapping("/audio") class AudioController { private val mContext by lazy { AirControllerApp.getInstance() } + private val mGson by lazy { Gson() } @PostMapping("/all") @ResponseBody @@ -51,46 +57,23 @@ class AudioController { @PostMapping("/delete") @ResponseBody fun delete( - httpRequest: HttpRequest, - @RequestBody request: DeleteAudioRequest + @RequestBody request: IdsRequest ): HttpResponseEntity { - val languageCode = httpRequest.getHeader("languageCode") - val locale = if (!TextUtils.isEmpty(languageCode)) Locale(languageCode!!) else Locale("en") - - try { - val paths = request.paths - - for (path in paths) { - val audioFile = File(path) - - val isSuccess = audioFile.delete() - if (!isSuccess) { - val response = ErrorBuilder().locale(locale).module(HttpModule.AudioModule) - .error(HttpError.DeleteAudioFail).build() - response.msg = - mContext.getString(locale, R.string.delete_audio_file_fail).replace( - "%s", - audioFile.absolutePath - ) - return response - } else { - MediaScannerConnection.scanFile( - mContext, - arrayOf(audioFile.absolutePath), - null, - null - ) - } + val deleteResult = AudioUtil.deleteByIds(mContext, request.ids) + + return when (deleteResult.result) { + DeleteResult.SUCCESS -> { + HttpResponseEntity.success() + } + DeleteResult.PARTIAL -> { + val response = ErrorBuilder().module(HttpModule.AudioModule).error(HttpError.DeleteAudioPartialFailure).build() + response.msg = response.msg?.format("%s", deleteResult.failedCount.toString()) + response + } + else -> { + ErrorBuilder().module(HttpModule.AudioModule).error(HttpError.DeleteAudioFail).build() } - } catch (e: Exception) { - e.printStackTrace() - val response = ErrorBuilder().locale(locale).module(HttpModule.AudioModule) - .error(HttpError.DeleteAudioFail).build() - response.msg = e.message - return response } - - return HttpResponseEntity.success() } @GetMapping("/item/{id}") @@ -141,4 +124,69 @@ class AudioController { } ?: return ErrorBuilder().module(HttpModule.AudioModule).error(HttpError.UploadAudioFail) .build() } + + @GetMapping("/download") + fun download(@QueryParam("ids") ids: String): File? { + val idList = mGson.fromJson>(ids, object : TypeToken>() {}.type) + + if (idList.isEmpty()) return null + + if (idList.size == 1) { + val id = idList[0] + val image = AudioUtil.findById(mContext, id) ?: return null + + val file = File(image.path) + + if (file.isFile) { + if (file.exists()) { + return file + } + } + + return null + } + + val audios = mutableListOf() + idList.forEach { id -> + AudioUtil.findById(mContext, id)?.apply { + audios.add(this) + } + } + + CommonUtil.findZipCacheWithPaths(mContext, audios.map { it.path })?.apply { return this } + + return compressAudios(audios).file + } + + private fun compressAudios(audios: List): ZipFile { + val db = RoomDatabaseHolder.getRoomDatabase(mContext) + val zipFileRecordDao = db.zipFileRecordDao() + + val originalFilesMD5Json = mutableMapOf() + val sortedOriginalPathsMD5 = MD5Helper.md5(audios.map { it.path }.sorted().joinToString(",")) + + val zipFile = ZipFile("${PathHelper.zipFileDir().absolutePath}/audios_${System.currentTimeMillis()}.zip") + + audios.forEach { + val file = File(it.path) + + zipFile.addFile(file) + + originalFilesMD5Json[file.absolutePath] = MD5Helper.md5(file) + } + + val record = ZipFileRecord( + name = zipFile.file.name, + path = zipFile.file.path, + md5 = MD5Helper.md5(zipFile.file), + originalFilesMD5 = mGson.toJson(originalFilesMD5Json), + originalPathsMD5 = sortedOriginalPathsMD5, + createTime = System.currentTimeMillis(), + isMultiOriginalFile = true + ) + + zipFileRecordDao.insert(record) + + return zipFile + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt index bcd2c95..b707186 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/FileController.kt @@ -17,10 +17,12 @@ import com.youngfeng.android.assistant.event.RequestPermissionsEvent import com.youngfeng.android.assistant.ext.isValidFileName import com.youngfeng.android.assistant.server.HttpError import com.youngfeng.android.assistant.server.HttpModule +import com.youngfeng.android.assistant.server.entity.DeleteResult import com.youngfeng.android.assistant.server.entity.FileEntity import com.youngfeng.android.assistant.server.entity.HttpResponseEntity import com.youngfeng.android.assistant.server.request.* import com.youngfeng.android.assistant.server.util.ErrorBuilder +import com.youngfeng.android.assistant.util.CommonUtil import net.lingala.zip4j.ZipFile import org.greenrobot.eventbus.EventBus import pub.devrel.easypermissions.EasyPermissions @@ -235,59 +237,23 @@ open class FileController { @PostMapping("/deleteMulti") @ResponseBody fun deleteMulti( - httpRequest: HttpRequest, @RequestBody request: DeleteMultiFileRequest ): HttpResponseEntity { - val languageCode = httpRequest.getHeader("languageCode") - val locale = if (!TextUtils.isEmpty(languageCode)) Locale(languageCode!!) else Locale("en") - - // 先判断是否存在写入外部存储权限 - if (ContextCompat.checkSelfPermission( - AirControllerApp.getInstance(), - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) != PackageManager.PERMISSION_GRANTED - ) { - return ErrorBuilder().locale(locale).module(HttpModule.FileModule) - .error(HttpError.NoWriteExternalStoragePerm).build() - } - - try { - val paths = request.paths - - var deleteCount = 0 - - paths.forEachIndexed { index, path -> - val file = File(path) - - if (file.exists()) { - if (file.isDirectory) { - if (file.deleteRecursively()) { - deleteCount++ - MediaScannerConnection.scanFile(mContext, arrayOf(path), null, null) - } - } else { - if (file.delete()) { - deleteCount++ - MediaScannerConnection.scanFile(mContext, arrayOf(path), null, null) - } - } - } + val files = request.paths.map { File(it) } + val deleteResult = CommonUtil.deleteFiles(files) + return when (deleteResult.result) { + DeleteResult.SUCCESS -> { + HttpResponseEntity.success() } - - if (deleteCount == paths.size) { - return HttpResponseEntity.success() - } else { - val response = ErrorBuilder().locale(locale).module(HttpModule.FileModule) - .error(HttpError.DeleteFileFail).build() - response.msg = "Delete ${deleteCount} success,${paths.size - deleteCount} failure." - return response + DeleteResult.PARTIAL -> { + val response = ErrorBuilder().module(HttpModule.FileModule) + .error(HttpError.DeleteFilePartialFailure).build() + response.msg = response.msg?.format("%s", deleteResult.failedCount.toString()) + response + } + else -> { + ErrorBuilder().module(HttpModule.FileModule).error(HttpError.DeleteFileFail).build() } - } catch (e: IOException) { - e.printStackTrace() - val response = ErrorBuilder().locale(locale).module(HttpModule.FileModule) - .error(HttpError.DeleteFileFail).build() - response.msg = e.message - return response } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt index e805d82..7bb27e0 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/ImageController.kt @@ -1,38 +1,41 @@ package com.youngfeng.android.assistant.server.controller import android.media.MediaScannerConnection -import android.text.TextUtils +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.yanzhenjie.andserver.annotation.CrossOrigin +import com.yanzhenjie.andserver.annotation.GetMapping import com.yanzhenjie.andserver.annotation.PostMapping +import com.yanzhenjie.andserver.annotation.QueryParam import com.yanzhenjie.andserver.annotation.RequestBody import com.yanzhenjie.andserver.annotation.RequestMapping import com.yanzhenjie.andserver.annotation.RequestParam import com.yanzhenjie.andserver.annotation.ResponseBody import com.yanzhenjie.andserver.annotation.RestController -import com.yanzhenjie.andserver.http.HttpRequest import com.yanzhenjie.andserver.http.multipart.MultipartFile -import com.youngfeng.android.assistant.R import com.youngfeng.android.assistant.app.AirControllerApp -import com.youngfeng.android.assistant.ext.getString +import com.youngfeng.android.assistant.db.RoomDatabaseHolder +import com.youngfeng.android.assistant.db.entity.ZipFileRecord import com.youngfeng.android.assistant.server.HttpError import com.youngfeng.android.assistant.server.HttpModule import com.youngfeng.android.assistant.server.entity.* -import com.youngfeng.android.assistant.server.request.DeleteAlbumsRequest -import com.youngfeng.android.assistant.server.request.DeleteImageRequest import com.youngfeng.android.assistant.server.request.GetAlbumImagesRequest +import com.youngfeng.android.assistant.server.request.IdsRequest import com.youngfeng.android.assistant.server.util.ErrorBuilder +import com.youngfeng.android.assistant.util.CommonUtil +import com.youngfeng.android.assistant.util.MD5Helper import com.youngfeng.android.assistant.util.PathHelper import com.youngfeng.android.assistant.util.PhotoUtil -import timber.log.Timber +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.model.ZipParameters import java.io.File -import java.lang.Exception -import java.util.Locale @CrossOrigin @RestController @RequestMapping("/image") class ImageController { private val mContext by lazy { AirControllerApp.getInstance() } + private val mGson by lazy { Gson() } companion object { private const val POS_ALL = 1 @@ -71,116 +74,46 @@ class ImageController { return HttpResponseEntity.success(images) } - @PostMapping("/delete") + @PostMapping("/deleteImages") @ResponseBody - fun deleteImage( - httpRequest: HttpRequest, - @RequestBody request: DeleteImageRequest + fun deleteImages( + @RequestBody request: IdsRequest ): HttpResponseEntity { - val languageCode = httpRequest.getHeader("languageCode") - val locale = if (!TextUtils.isEmpty(languageCode)) Locale(languageCode!!) else Locale("en") - - try { - val resultMap = HashMap() - val imageFiles = ArrayList() - var isAllSuccess = true - request.paths.forEach { imgPath -> - val imageFile = File(imgPath) - imageFiles.add(imageFile.absolutePath) - if (!imageFile.exists()) { - isAllSuccess = false - resultMap[imgPath] = - mContext.getString(locale, HttpError.ImageFileNotExist.value) - } else { - val isSuccess = imageFile.delete() - if (!isSuccess) { - isAllSuccess = false - resultMap[imgPath] = - mContext.getString(locale, HttpError.DeleteImageFail.value) - } - } + val deleteResult = PhotoUtil.deleteImageByIds(mContext, request.ids) + return when (deleteResult.result) { + DeleteResult.SUCCESS -> { + HttpResponseEntity.success() } - if (imageFiles.size > 0) { - MediaScannerConnection.scanFile( - mContext, - imageFiles.toTypedArray(), - null - ) { path, uri -> - Timber.d("Path: $path, uri: ${uri?.path}") - } + DeleteResult.PARTIAL -> { + val response = ErrorBuilder().module(HttpModule.ImageModule).error(HttpError.DeleteImagePartialFailure).build() + response.msg = response.msg?.format("%s", deleteResult.failedCount.toString()) + response } - if (!isAllSuccess) { - val response = ErrorBuilder().locale(locale).module(HttpModule.ImageModule) - .error(HttpError.DeleteImageFail).build() - response.msg = resultMap.map { "${it.key}[${it.value}];" }.toString() - return response + else -> { + ErrorBuilder().module(HttpModule.ImageModule).error(HttpError.DeleteImageFail).build() } - } catch (e: Exception) { - e.printStackTrace() - val response = ErrorBuilder().locale(locale).module(HttpModule.ImageModule) - .error(HttpError.DeleteImageFail).build() - response.msg = e.message - return response } - - return HttpResponseEntity.success() } @PostMapping("/deleteAlbums") @ResponseBody fun deleteAlbums( - httpRequest: HttpRequest, - @RequestBody request: DeleteAlbumsRequest + @RequestBody request: IdsRequest ): HttpResponseEntity { - val languageCode = httpRequest.getHeader("languageCode") - val locale = if (!TextUtils.isEmpty(languageCode)) Locale(languageCode!!) else Locale("en") - try { - val paths = request.paths - - var deleteItemNum = 0 - paths.forEach { path -> - val file = File(path) - if (!file.exists()) { - val response = ErrorBuilder().locale(locale).module(HttpModule.ImageModule) - .error(HttpError.DeleteAlbumFail).build() - response.msg = convertToDeleteAlbumError(locale, paths.size, deleteItemNum) - return response - } else { - val isSuccess = file.deleteRecursively() - if (!isSuccess) { - val response = ErrorBuilder().locale(locale).module(HttpModule.ImageModule) - .error(HttpError.DeleteAlbumFail).build() - response.msg = convertToDeleteAlbumError(locale, paths.size, deleteItemNum) - return response - } else { - MediaScannerConnection.scanFile(mContext, arrayOf(path), null, null) - } - } - - deleteItemNum++ + val deleteResult = PhotoUtil.deleteAlbumByIds(mContext, request.ids) + return when (deleteResult.result) { + DeleteResult.SUCCESS -> { + HttpResponseEntity.success() + } + DeleteResult.PARTIAL -> { + val response = ErrorBuilder().module(HttpModule.ImageModule).error(HttpError.DeleteAlbumPartialFailure).build() + response.msg = response.msg?.format("%s", deleteResult.failedCount.toString()) + response + } + else -> { + ErrorBuilder().module(HttpModule.ImageModule).error(HttpError.DeleteAlbumFail).build() } - - return HttpResponseEntity.success() - } catch (e: Exception) { - e.printStackTrace() - val response = ErrorBuilder().locale(locale).module(HttpModule.ImageModule) - .error(HttpError.DeleteAlbumFail).build() - response.msg = e.message - return response - } - } - - private fun convertToDeleteAlbumError( - locale: Locale, - albumNum: Int, - deletedItemNum: Int - ): String { - if (deletedItemNum > 0) { - return mContext.getString(locale, R.string.place_holder_delete_part_of_success) - .format(albumNum, deletedItemNum) } - - return mContext.getString(locale, R.string.delete_album_fail) } @PostMapping("/imagesOfAlbum") @@ -235,4 +168,138 @@ class ImageController { return HttpResponseEntity.success(images) } + + @GetMapping("/downloadImages") + fun downloadImages(@QueryParam("ids") ids: String): File? { + val idList = mGson.fromJson>(ids, object : TypeToken>() {}.type) + + if (idList.isEmpty()) return null + + if (idList.size == 1) { + val id = idList[0] + val image = PhotoUtil.findImageById(mContext, id) ?: return null + + val file = File(image.path) + + if (file.isFile) { + if (file.exists()) { + return file + } + } + + return null + } + + val images = mutableListOf() + idList.forEach { id -> + PhotoUtil.findImageById(mContext, id)?.apply { + images.add(this) + } + } + + CommonUtil.findZipCacheWithPaths(mContext, images.map { it.path })?.apply { return this } + + return compressImages(images).file + } + + @GetMapping("/downloadAlbums") + fun downloadAlbums(@QueryParam("ids") ids: String): File? { + val idList = mGson.fromJson>(ids, object : TypeToken>() {}.type) + + if (idList.isEmpty()) return null + + if (idList.size == 1) { + val id = idList[0] + val images = PhotoUtil.getImagesOfAlbum(mContext, id) + if (images.isEmpty()) return null + + CommonUtil.findZipCacheWithPaths(mContext, images.map { it.path })?.apply { return this } + } + + val albums = mutableListOf() + val videos = mutableListOf() + idList.forEach { + PhotoUtil.findAlbumById(mContext, it)?.apply { + albums.add(this) + PhotoUtil.getImagesOfAlbum(mContext, this.id).apply { + videos.addAll(this) + } + } + } + CommonUtil.findZipCacheWithPaths(mContext, videos.map { it.path }.toMutableList())?.apply { return this } + + return compressAlbums(albums).file + } + + private fun compressAlbums(albums: List): ZipFile { + val db = RoomDatabaseHolder.getRoomDatabase(mContext) + val zipFileRecordDao = db.zipFileRecordDao() + + val originalFilesMD5Json = mutableMapOf() + + val zipFile = ZipFile("${PathHelper.zipFileDir().absolutePath}/albums_${System.currentTimeMillis()}.zip") + + val videos = mutableListOf() + albums.forEach { album -> + PhotoUtil.findAlbumById(mContext, album.id)?.apply { + PhotoUtil.getImagesOfAlbum(mContext, this.id).onEach { + val file = File(it.path) + + zipFile.addFile(file, ZipParameters().apply { fileNameInZip = "${album.name}/${file.name}" }) + + originalFilesMD5Json[file.absolutePath] = MD5Helper.md5(file) + + videos.add(it) + } + } + } + + val sortedOriginalPathsMD5 = MD5Helper.md5(videos.map { it.path }.sorted().joinToString(",")) + + val record = ZipFileRecord( + name = zipFile.file.name, + path = zipFile.file.path, + md5 = MD5Helper.md5(zipFile.file), + originalFilesMD5 = mGson.toJson(originalFilesMD5Json), + originalPathsMD5 = sortedOriginalPathsMD5, + createTime = System.currentTimeMillis(), + isMultiOriginalFile = true + ) + + zipFileRecordDao.insert(record) + + return zipFile + } + + private fun compressImages(images: List): ZipFile { + val db = RoomDatabaseHolder.getRoomDatabase(mContext) + val zipFileRecordDao = db.zipFileRecordDao() + + val originalFilesMD5Json = mutableMapOf() + val sortedOriginalPathsMD5 = MD5Helper.md5(images.map { it.path }.sorted().joinToString(",")) + + val zipFile = ZipFile("${PathHelper.zipFileDir().absolutePath}/images_${System.currentTimeMillis()}.zip") + + images.forEach { + val file = File(it.path) + + zipFile.addFile(file) + + originalFilesMD5Json[file.absolutePath] = MD5Helper.md5(file) + } + + val record = ZipFileRecord( + name = zipFile.file.name, + path = zipFile.file.path, + md5 = MD5Helper.md5(zipFile.file), + originalFilesMD5 = mGson.toJson(originalFilesMD5Json), + originalPathsMD5 = sortedOriginalPathsMD5, + createTime = System.currentTimeMillis(), + isMultiOriginalFile = true + ) + + zipFileRecordDao.insert(record) + + return zipFile + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt index 86c1673..9a18694 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/controller/VideoController.kt @@ -2,11 +2,13 @@ package com.youngfeng.android.assistant.server.controller import android.Manifest import android.media.MediaScannerConnection -import android.text.TextUtils +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.yanzhenjie.andserver.annotation.CrossOrigin import com.yanzhenjie.andserver.annotation.GetMapping import com.yanzhenjie.andserver.annotation.PathVariable import com.yanzhenjie.andserver.annotation.PostMapping +import com.yanzhenjie.andserver.annotation.QueryParam import com.yanzhenjie.andserver.annotation.RequestBody import com.yanzhenjie.andserver.annotation.RequestMapping import com.yanzhenjie.andserver.annotation.RequestParam @@ -16,33 +18,37 @@ import com.yanzhenjie.andserver.http.HttpRequest import com.yanzhenjie.andserver.http.HttpResponse import com.yanzhenjie.andserver.http.multipart.MultipartFile import com.yanzhenjie.andserver.util.MediaType -import com.youngfeng.android.assistant.R import com.youngfeng.android.assistant.app.AirControllerApp +import com.youngfeng.android.assistant.db.RoomDatabaseHolder +import com.youngfeng.android.assistant.db.entity.ZipFileRecord import com.youngfeng.android.assistant.event.Permission import com.youngfeng.android.assistant.event.RequestPermissionsEvent -import com.youngfeng.android.assistant.ext.getString import com.youngfeng.android.assistant.server.HttpError import com.youngfeng.android.assistant.server.HttpModule +import com.youngfeng.android.assistant.server.entity.DeleteResult import com.youngfeng.android.assistant.server.entity.HttpResponseEntity import com.youngfeng.android.assistant.server.entity.VideoEntity import com.youngfeng.android.assistant.server.entity.VideoFolder -import com.youngfeng.android.assistant.server.request.DeleteVideosRequest import com.youngfeng.android.assistant.server.request.GetVideosRequest +import com.youngfeng.android.assistant.server.request.IdsRequest import com.youngfeng.android.assistant.server.response.RangeSupportResponseBody import com.youngfeng.android.assistant.server.util.ErrorBuilder +import com.youngfeng.android.assistant.util.CommonUtil +import com.youngfeng.android.assistant.util.MD5Helper import com.youngfeng.android.assistant.util.PathHelper import com.youngfeng.android.assistant.util.VideoUtil +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.model.ZipParameters import org.greenrobot.eventbus.EventBus import pub.devrel.easypermissions.EasyPermissions import java.io.File -import java.lang.Exception -import java.util.Locale @CrossOrigin @RestController @RequestMapping("/video") class VideoController { private val mContext by lazy { AirControllerApp.getInstance() } + private val mGson by lazy { Gson() } @PostMapping("/folders") @ResponseBody @@ -63,51 +69,42 @@ class VideoController { return HttpResponseEntity.success(videos) } - @PostMapping("/delete") - fun delete(httpRequest: HttpRequest, @RequestBody request: DeleteVideosRequest): HttpResponseEntity { - val languageCode = httpRequest.getHeader("languageCode") - val locale = if (!TextUtils.isEmpty(languageCode)) Locale(languageCode!!) else Locale("en") - - try { - val paths = request.paths - - var deleteItemNum = 0 - paths.forEach { path -> - val file = File(path) - if (!file.exists()) { - val response = ErrorBuilder().locale(locale).module(HttpModule.VideoModule).error(HttpError.DeleteVideoFail).build() - response.msg = convertToDeleteVideoError(locale, paths.size, deleteItemNum) - return response - } else { - val isSuccess = file.deleteRecursively() - if (!isSuccess) { - val response = ErrorBuilder().locale(locale).module(HttpModule.VideoModule).error(HttpError.DeleteVideoFail).build() - response.msg = convertToDeleteVideoError(locale, paths.size, deleteItemNum) - return response - } else { - MediaScannerConnection.scanFile(mContext, arrayOf(path), null, null) - } - } - - deleteItemNum ++ + @PostMapping("/deleteVideos") + @ResponseBody + fun deleteVideos(@RequestBody request: IdsRequest): HttpResponseEntity { + val deleteResult = VideoUtil.deleteVideoByIds(mContext, request.ids) + return when (deleteResult.result) { + DeleteResult.SUCCESS -> { + HttpResponseEntity.success() + } + DeleteResult.PARTIAL -> { + val response = ErrorBuilder().module(HttpModule.VideoModule).error(HttpError.DeleteVideoPartialFailure).build() + response.msg = response.msg?.format("%s", deleteResult.failedCount.toString()) + response + } + else -> { + ErrorBuilder().module(HttpModule.VideoModule).error(HttpError.DeleteVideoFail).build() } - - return HttpResponseEntity.success() - } catch (e: Exception) { - e.printStackTrace() - val response = ErrorBuilder().locale(locale).module(HttpModule.ImageModule).error(HttpError.DeleteAlbumFail).build() - response.msg = e.message - return response } } - private fun convertToDeleteVideoError(locale: Locale, albumNum: Int, deletedItemNum: Int): String { - if (deletedItemNum > 0) { - return mContext.getString(locale, R.string.place_holder_delete_part_of_success) - .format(albumNum, deletedItemNum) + @PostMapping("/deleteVideoFolders") + @ResponseBody + fun deleteVideoFolders(@RequestBody request: IdsRequest): HttpResponseEntity { + val deleteResult = VideoUtil.deleteVideoFolderByIds(mContext, request.ids) + return when (deleteResult.result) { + DeleteResult.SUCCESS -> { + HttpResponseEntity.success() + } + DeleteResult.PARTIAL -> { + val response = ErrorBuilder().module(HttpModule.VideoModule).error(HttpError.DeleteVideoFolderPartialFailure).build() + response.msg = response.msg?.format("%s", deleteResult.failedCount.toString()) + response + } + else -> { + ErrorBuilder().module(HttpModule.VideoModule).error(HttpError.DeleteVideoFolderFail).build() + } } - - return mContext.getString(locale, R.string.delete_video_file_fail) } @CrossOrigin @@ -166,4 +163,141 @@ class VideoController { } ?: return ErrorBuilder().module(HttpModule.VideoModule).error(HttpError.UploadVideoFailure) .build() } + + @CrossOrigin + @GetMapping("/downloadVideos") + fun downloadVideos(@QueryParam("ids") ids: String): File? { + val idList = mGson.fromJson>(ids, object : TypeToken>() {}.type) + + if (idList.isEmpty()) return null + + if (idList.size == 1) { + val id = idList[0] + val video = VideoUtil.findById(mContext, id) ?: return null + + val file = File(video.path) + + if (file.isFile) { + if (file.exists()) { + return file + } + } + + return null + } + + val videos = mutableListOf() + idList.forEach { id -> + VideoUtil.findById(mContext, id)?.apply { + videos.add(this) + } + } + + CommonUtil.findZipCacheWithPaths(mContext, videos.map { it.path })?.apply { return this } + + return compressVideos(videos).file + } + + @CrossOrigin + @GetMapping("/downloadVideoFolders") + fun downloadVideoFolders(@QueryParam("ids") ids: String): File? { + val idList = mGson.fromJson>(ids, object : TypeToken>() {}.type) + + if (idList.isEmpty()) return null + + if (idList.size == 1) { + val id = idList[0] + val videos = VideoUtil.getVideosByFolderId(mContext, id) + if (videos.isEmpty()) return null + + CommonUtil.findZipCacheWithPaths(mContext, videos.map { it.path })?.apply { return this } + } + + val videoFolders = mutableListOf() + val videos = mutableListOf() + idList.forEach { + VideoUtil.findVideoFolderById(mContext, it)?.apply { + videoFolders.add(this) + + VideoUtil.getVideosByFolderId(mContext, id).apply { + videos.addAll(this) + } + } + } + CommonUtil.findZipCacheWithPaths(mContext, videos.map { it.path }.toMutableList())?.apply { return this } + + return compressVideoFolders(videoFolders).file + } + + private fun compressVideoFolders(videoFolders: List): ZipFile { + val db = RoomDatabaseHolder.getRoomDatabase(mContext) + val zipFileRecordDao = db.zipFileRecordDao() + + val originalFilesMD5Json = mutableMapOf() + + val zipFile = ZipFile("${PathHelper.zipFileDir().absolutePath}/videoFolders_${System.currentTimeMillis()}.zip") + + val videos = mutableListOf() + videoFolders.forEach { videoFolder -> + VideoUtil.findVideoFolderById(mContext, videoFolder.id)?.apply { + VideoUtil.getVideosByFolderId(mContext, this.id).onEach { + val file = File(it.path) + + originalFilesMD5Json[file.absolutePath] = MD5Helper.md5(file) + + zipFile.addFile(file, ZipParameters().apply { fileNameInZip = "${videoFolder.name}/${file.name}" }) + + videos.add(it) + } + } + } + + val sortedOriginalPathsMD5 = MD5Helper.md5(videos.map { it.path }.sorted().joinToString(",")) + + val record = ZipFileRecord( + name = zipFile.file.name, + path = zipFile.file.path, + md5 = MD5Helper.md5(zipFile.file), + originalFilesMD5 = mGson.toJson(originalFilesMD5Json), + originalPathsMD5 = sortedOriginalPathsMD5, + createTime = System.currentTimeMillis(), + isMultiOriginalFile = true + ) + + zipFileRecordDao.insert(record) + + return zipFile + } + + private fun compressVideos(videos: List): ZipFile { + val db = RoomDatabaseHolder.getRoomDatabase(mContext) + val zipFileRecordDao = db.zipFileRecordDao() + + val originalFilesMD5Json = mutableMapOf() + val sortedOriginalPathsMD5 = MD5Helper.md5(videos.map { it.path }.sorted().joinToString(",")) + + val zipFile = ZipFile("${PathHelper.zipFileDir().absolutePath}/videos_${System.currentTimeMillis()}.zip") + + videos.forEach { + val file = File(it.path) + + zipFile.addFile(file) + + originalFilesMD5Json[file.absolutePath] = MD5Helper.md5(file) + } + + val record = ZipFileRecord( + name = zipFile.file.name, + path = zipFile.file.path, + md5 = MD5Helper.md5(zipFile.file), + originalFilesMD5 = mGson.toJson(originalFilesMD5Json), + originalPathsMD5 = sortedOriginalPathsMD5, + createTime = System.currentTimeMillis(), + isMultiOriginalFile = true + ) + + zipFileRecordDao.insert(record) + + return zipFile + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/entity/DeleteResult.kt b/app/src/main/java/com/youngfeng/android/assistant/server/entity/DeleteResult.kt new file mode 100644 index 0000000..1a81c54 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/server/entity/DeleteResult.kt @@ -0,0 +1,18 @@ +package com.youngfeng.android.assistant.server.entity + +enum class DeleteResult { + SUCCESS, + FAILED, + PARTIAL +} + +data class DeleteResultEntity( + val result: DeleteResult, + val failedCount: Int = 0 +) { + companion object { + fun success() = DeleteResultEntity(DeleteResult.SUCCESS) + fun failed() = DeleteResultEntity(DeleteResult.FAILED) + fun partial(failedCount: Int) = DeleteResultEntity(DeleteResult.PARTIAL, failedCount) + } +} diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/request/DeleteMultiFileRequest.kt b/app/src/main/java/com/youngfeng/android/assistant/server/request/DeleteMultiFileRequest.kt index 94d885a..aa4077f 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/server/request/DeleteMultiFileRequest.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/server/request/DeleteMultiFileRequest.kt @@ -1,3 +1,3 @@ package com.youngfeng.android.assistant.server.request -class DeleteMultiFileRequest(var paths: List) +class DeleteMultiFileRequest(var paths: List, var type: Int) diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/request/IdsRequest.kt b/app/src/main/java/com/youngfeng/android/assistant/server/request/IdsRequest.kt new file mode 100644 index 0000000..f0d966b --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/server/request/IdsRequest.kt @@ -0,0 +1,3 @@ +package com.youngfeng.android.assistant.server.request + +data class IdsRequest(val ids: List) diff --git a/app/src/main/java/com/youngfeng/android/assistant/util/AudioUtil.kt b/app/src/main/java/com/youngfeng/android/assistant/util/AudioUtil.kt index 6faf143..e4546c9 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/util/AudioUtil.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/util/AudioUtil.kt @@ -1,8 +1,11 @@ package com.youngfeng.android.assistant.util import android.content.Context +import android.media.MediaScannerConnection import android.provider.MediaStore import com.youngfeng.android.assistant.server.entity.AudioEntity +import com.youngfeng.android.assistant.server.entity.DeleteResultEntity +import java.io.File object AudioUtil { @@ -122,4 +125,43 @@ object AudioUtil { return null } + + fun deleteByIds(context: Context, ids: List): DeleteResultEntity { + var successCount = 0 + ids.forEach { + findById(context, it)?.apply { + if (delete(context, this)) { + successCount ++ + } + } + } + + return if (successCount == ids.size) { + DeleteResultEntity.success() + } else if (successCount > 0 && successCount < ids.size) { + DeleteResultEntity.partial(failedCount = ids.size - successCount) + } else { + DeleteResultEntity.failed() + } + } + + fun delete(context: Context, audio: AudioEntity): Boolean { + try { + val file = File(audio.path) + if (file.exists()) { + if (file.delete()) { + MediaScannerConnection.scanFile( + context, + arrayOf(audio.path), + arrayOf("audio/*") + ) { _, _ -> } + return true + } + } + return false + } catch (e: Exception) { + e.printStackTrace() + return false + } + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt b/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt index b1adde3..6c9a2f1 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/util/CommonUtil.kt @@ -9,8 +9,12 @@ import android.os.Build import android.os.Environment import android.provider.Settings import androidx.core.content.FileProvider +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.youngfeng.android.assistant.db.RoomDatabaseHolder import com.youngfeng.android.assistant.model.StorageSize import com.youngfeng.android.assistant.server.entity.ApkInfo +import com.youngfeng.android.assistant.server.entity.DeleteResultEntity import java.io.File import java.lang.reflect.Field import java.lang.reflect.Method @@ -105,4 +109,80 @@ object CommonUtil { } } } + + fun findZipCacheWithPaths(context: Context, paths: List): File? { + val db = RoomDatabaseHolder.getRoomDatabase(context) + val zipFileRecordDao = db.zipFileRecordDao() + val sortedOriginalPathsMD5 = MD5Helper.md5(paths.sorted().joinToString(",")) + + val zipFileRecord = + zipFileRecordDao.findByOriginalPathsMd5(sortedOriginalPathsMD5).singleOrNull() + + if (null != zipFileRecord) { + if (zipFileRecord.isMultiOriginalFile) { + var isMatch = true + + val gson = Gson() + val originalFileMD5Map = gson.fromJson>( + zipFileRecord.originalFilesMD5, + object : TypeToken>() {}.type + ) + + kotlin.run { + paths.forEach { + val current = File(it) + + if (MD5Helper.md5(current) != originalFileMD5Map[current.absolutePath]) { + isMatch = false + return@run + } + } + } + + if (isMatch) { + val zipOldFile = File(zipFileRecord.path) + + if (zipOldFile.exists()) return zipOldFile + } + } + } + + return null + } + + fun deleteFiles(files: List): DeleteResultEntity { + try { + var successCount = 0 + + files.forEach { + if (deleteFile(it)) { + successCount++ + } + } + + return if (successCount == files.size) { + DeleteResultEntity.success() + } else if (successCount > 0 && successCount < files.size) { + DeleteResultEntity.partial(failedCount = files.size - successCount) + } else { + DeleteResultEntity.failed() + } + } catch (e: Exception) { + e.printStackTrace() + return DeleteResultEntity.failed() + } + } + + fun deleteFile(file: File): Boolean { + return try { + if (file.exists()) { + file.delete() + } else { + false + } + } catch (e: Exception) { + e.printStackTrace() + false + } + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/util/PhotoUtil.kt b/app/src/main/java/com/youngfeng/android/assistant/util/PhotoUtil.kt index 66bc55f..a02c6ca 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/util/PhotoUtil.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/util/PhotoUtil.kt @@ -1,11 +1,14 @@ package com.youngfeng.android.assistant.util import android.content.Context +import android.media.MediaScannerConnection import android.os.Environment import android.provider.MediaStore import android.text.TextUtils import com.youngfeng.android.assistant.server.entity.AlbumEntity +import com.youngfeng.android.assistant.server.entity.DeleteResultEntity import com.youngfeng.android.assistant.server.entity.ImageEntity +import java.io.File object PhotoUtil { @@ -293,4 +296,194 @@ object PhotoUtil { return result } + + fun findImageById(context: Context, id: String): ImageEntity? { + val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + val projections = arrayOf( + MediaStore.Images.ImageColumns._ID, + MediaStore.Images.ImageColumns.DATA, + MediaStore.Images.ImageColumns.DATE_MODIFIED, + MediaStore.Images.ImageColumns.MINI_THUMB_MAGIC, + MediaStore.Images.ImageColumns.MIME_TYPE, + MediaStore.Images.ImageColumns.WIDTH, + MediaStore.Images.ImageColumns.HEIGHT, + MediaStore.Images.ImageColumns.DATE_TAKEN, + MediaStore.Images.ImageColumns.DISPLAY_NAME, + MediaStore.Images.ImageColumns.SIZE + ) + + val orderBy = "${MediaStore.Images.ImageColumns.DATE_TAKEN} DESC" + + val selection = "${MediaStore.Images.ImageColumns._ID} = ?" + val selectionArgs = arrayOf(id) + + var result: ImageEntity? = null + + context.contentResolver.query(contentUri, projections, selection, selectionArgs, orderBy, null)?.use { + if (it.moveToFirst()) { + val idIndex = it.getColumnIndex(MediaStore.Images.ImageColumns._ID) + val imageDataIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.DATA) + val dateModifiedIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.DATE_MODIFIED) + val miniThumbMagicIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.MINI_THUMB_MAGIC) + val mimeTypeIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.MIME_TYPE) + val widthIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.WIDTH) + val heightIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.HEIGHT) + val dateTakenIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.DATE_TAKEN) + val displayNameIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.DISPLAY_NAME) + val sizeIndex = it.getColumnIndex(MediaStore.Images.ImageColumns.SIZE) + + val id = it.getString(idIndex) + val imageData = it.getString(imageDataIndex) + val modifyDate = it.getLong(dateModifiedIndex) + val thumbnail = it.getString(miniThumbMagicIndex) + val mimeType = it.getString(mimeTypeIndex) + val width = it.getInt(widthIndex) + val height = it.getInt(heightIndex) + val dateTaken = it.getLong(dateTakenIndex) + val displayName = it.getString(displayNameIndex) + val size = it.getLong(sizeIndex); + + result = ImageEntity(id, mimeType, thumbnail, imageData, width, height, modifyDate, dateTaken, displayName, size) + } + } + + return result + } + + fun deleteImageByIds(context: Context, ids: List): DeleteResultEntity { + var successCount = 0 + ids.forEach { id -> + findImageById(context, id)?.let { + if (deleteImage(context, it)) { + successCount ++ + } + } + } + + return if (successCount == ids.size) { + DeleteResultEntity.success() + } else if (successCount > 0 && successCount < ids.size) { + DeleteResultEntity.partial(failedCount = ids.size - successCount) + } else { + DeleteResultEntity.failed() + } + } + + fun deleteImage(context: Context, image: ImageEntity): Boolean { + try { + val file = File(image.path) + if (file.exists()) { + if (file.delete()) { + MediaScannerConnection.scanFile(context, arrayOf(file.path), arrayOf("image/*"), null) + return true + } + } + return false + } catch (e: Exception) { + e.printStackTrace() + return false + } + } + + fun deleteAlbumByIds(context: Context, ids: List): DeleteResultEntity { + var successCount = 0 + + ids.forEach { id -> + findAlbumById(context, id)?.let { + if (deleteAlbum(context, it)) { + successCount ++ + } + } + } + + return if (successCount == ids.size) { + DeleteResultEntity.success() + } else if (successCount > 0 && successCount < ids.size) { + DeleteResultEntity.partial(failedCount = ids.size - successCount) + } else { + DeleteResultEntity.failed() + } + } + + fun findAlbumById(context: Context, id: String): AlbumEntity? { + val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + val projections = arrayOf( + MediaStore.Images.ImageColumns._ID, + MediaStore.Images.ImageColumns.BUCKET_ID, + MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME, + MediaStore.Images.ImageColumns.DATA + ) + + val selection = "${MediaStore.Images.ImageColumns.BUCKET_ID} = ?" + val selectionArgs = arrayOf(id) + + val orderBy = "${MediaStore.Images.ImageColumns.DATE_TAKEN} DESC" + + val map = HashMap() + + context.contentResolver.query(contentUri, projections, selection, selectionArgs, orderBy)?.use { cursor -> + if (cursor.moveToFirst()) { + val bucketIdIndex = + cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID) + val bucketNameIndex = + cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME) + val imageUriIndex = + cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATA) + + val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID) + + do { + val bucketId = cursor.getString(bucketIdIndex) + + val album = map[bucketId] ?: let { + val bucketName = cursor.getString(bucketNameIndex) ?: "" + val coverImageId = cursor.getString(idIndex) + val imageUri = cursor.getString(imageUriIndex) + + var path = "" + if (!TextUtils.isEmpty(imageUri)) { + val index = imageUri.lastIndexOf("/") + + if (index != -1) { + path = imageUri.substring(0, index) + } + } + + val album = AlbumEntity( + id = bucketId, + name = bucketName, + coverImageId = coverImageId, + path = path + ) + map[bucketId] = album + + album + } + + album.photoNum++ + } while (cursor.moveToNext()) + } + } + + return map.values.firstOrNull() + } + + fun deleteAlbum(context: Context, album: AlbumEntity): Boolean { + val images = getImagesOfAlbum(context, album.id) + var isSuccess = true + for (image in images) { + if (!deleteImage(context, image)) { + isSuccess = false + break + } + } + + if (isSuccess) { + MediaScannerConnection.scanFile(context, arrayOf(album.path), arrayOf("image/*"), null) + } + + return isSuccess + } } diff --git a/app/src/main/java/com/youngfeng/android/assistant/util/VideoUtil.kt b/app/src/main/java/com/youngfeng/android/assistant/util/VideoUtil.kt index 74c22f5..183b28d 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/util/VideoUtil.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/util/VideoUtil.kt @@ -1,9 +1,12 @@ package com.youngfeng.android.assistant.util import android.content.Context +import android.media.MediaScannerConnection import android.provider.MediaStore +import com.youngfeng.android.assistant.server.entity.DeleteResultEntity import com.youngfeng.android.assistant.server.entity.VideoEntity import com.youngfeng.android.assistant.server.entity.VideoFolder +import java.io.File object VideoUtil { @@ -294,4 +297,144 @@ object VideoUtil { return null } + + fun deleteVideoByIds(context: Context, ids: List): DeleteResultEntity { + var successCount = 0 + ids.forEach { + findById(context, it)?.apply { + if (delete(context, this)) { + successCount ++ + } + } + } + + return if (successCount == ids.size) { + DeleteResultEntity.success() + } else if (successCount > 0 && successCount < ids.size) { + DeleteResultEntity.partial(failedCount = ids.size - successCount) + } else { + DeleteResultEntity.failed() + } + } + + fun delete(context: Context, video: VideoEntity): Boolean { + try { + val file = File(video.path) + if (file.exists()) { + if (file.delete()) { + MediaScannerConnection.scanFile( + context, + arrayOf(video.path), + arrayOf("video/*") + ) { _, _ -> } + return true + } + } + return false + } catch (e: Exception) { + e.printStackTrace() + return false + } + } + + fun findVideoFolderById(context: Context, id: String): VideoFolder? { + val projection = arrayOf( + MediaStore.Video.VideoColumns._ID, + MediaStore.Video.VideoColumns.BUCKET_ID, + MediaStore.Video.VideoColumns.DATA, + MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME + ) + + val selection = "${MediaStore.Video.VideoColumns.BUCKET_ID} = ?" + val selectionArgs = arrayOf(id) + + val orderBy = "${MediaStore.Video.VideoColumns.DATE_TAKEN} DESC" + val map = mutableMapOf() + + context.contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + orderBy, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val bucketIdIndex = + cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_ID) + val bucketNameIndex = + cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.BUCKET_DISPLAY_NAME) + val videoUriIndex = + cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.DATA) + val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns._ID) + + do { + val bucketId = cursor.getString(bucketIdIndex) + + val album = map[bucketId] ?: let { + val bucketName = cursor.getString(bucketNameIndex) + val videoId = cursor.getLong(idIndex) + val videoPath = cursor.getString(videoUriIndex); + + var folder = videoPath + val index = folder.lastIndexOf("/") + if (index != -1) { + folder = folder.substring(0, index) + } + + val album = VideoFolder( + id = bucketId, + name = bucketName ?: "Unknown folder", + coverVideoId = videoId, + path = folder + ) + map[bucketId] = album + + album + } + + album.videoCount++ + } while (cursor.moveToNext()) + } + } + + return map.values.firstOrNull() + } + + fun deleteVideoFolderByIds(context: Context, ids: List): DeleteResultEntity { + var successCount = 0 + + ids.forEach { id -> + findVideoFolderById(context, id)?.let { + if (deleteFolder(context, it)) { + successCount ++ + } + } + } + + return if (successCount == ids.size) { + DeleteResultEntity.success() + } else if (successCount > 0 && successCount < ids.size) { + DeleteResultEntity.partial(failedCount = ids.size - successCount) + } else { + DeleteResultEntity.failed() + } + } + + fun deleteFolder(context: Context, videoFolder: VideoFolder): Boolean { + val videos = getVideosByFolderId(context, videoFolder.id) + var isSuccess = true + for (video in videos) { + if (!delete(context, video)) { + isSuccess = false + break + } + } + + if (isSuccess) { + MediaScannerConnection.scanFile(context, arrayOf(videoFolder.path), arrayOf("image/*"), null) + } + + return isSuccess + } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 79ef0a5..708037b 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -95,4 +95,11 @@ The QR code has been saved to the photo album Reward any amount, support the author to continue to develop, thank you for your support! Watch Ads + Some pictures failed to delete, the number of failures: %s + Some albums failed to delete, the number of failures: %s + Some audios failed to delete, the number of failures: %s + Some videos failed to delete, the number of failures: %s + Some video folders failed to delete, the number of failures: %s + Delete video folders failed + Some files failed to delete, the number of failures: %s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f9896b..5291335 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,4 +95,11 @@ 二维码已保存到相册 打赏任意金额,支持作者继续开发,谢谢您的支持! 看广告 + 部分图片删除失败,失败数量:%s + 部分相册删除失败,失败数量:%s + 部分音频删除失败,失败数量:%s + 部分视频删除失败,失败数量:%s + 部分视频文件夹删除失败,失败数量:%s + 删除视频文件夹失败 + 部分文件删除失败,失败数量:%s \ No newline at end of file From f47b4429d71cb19581df75b0fea4cc8ef9aaf777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Wed, 30 Nov 2022 10:33:21 +0800 Subject: [PATCH 09/11] Remove "watch ad" button in the support author activity. --- .../support/DeveloperSupportActivity.kt | 2 -- .../res/layout/activity_developer_support.xml | 27 ------------------- 2 files changed, 29 deletions(-) diff --git a/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt index ce84a4b..837fb90 100644 --- a/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt +++ b/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt @@ -12,7 +12,6 @@ import com.youngfeng.android.assistant.view.RewardDialog class DeveloperSupportActivity : AppCompatActivity() { private val mStarBtn by lazy { findViewById(R.id.btn_star) } - private val mWatchAd by lazy { findViewById(R.id.btn_watch_ad) } private val mRewardBtn by lazy { findViewById(R.id.btn_reward) } private val mRewardDialog by lazy { RewardDialog(this) } @@ -36,7 +35,6 @@ class DeveloperSupportActivity : AppCompatActivity() { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } - mWatchAd.setOnClickListener { } mRewardBtn.setOnClickListener { mRewardDialog.show() } diff --git a/app/src/main/res/layout/activity_developer_support.xml b/app/src/main/res/layout/activity_developer_support.xml index 3ea49d7..0b6cd06 100644 --- a/app/src/main/res/layout/activity_developer_support.xml +++ b/app/src/main/res/layout/activity_developer_support.xml @@ -71,33 +71,6 @@ - - - - - - - - - Date: Fri, 2 Dec 2022 00:27:47 +0800 Subject: [PATCH 10/11] Add interceptor to add universal header `Access-Control-Allow-Private-Network: true` --- .../server/interceptor/CommonInterceptor.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/src/main/java/com/youngfeng/android/assistant/server/interceptor/CommonInterceptor.kt diff --git a/app/src/main/java/com/youngfeng/android/assistant/server/interceptor/CommonInterceptor.kt b/app/src/main/java/com/youngfeng/android/assistant/server/interceptor/CommonInterceptor.kt new file mode 100644 index 0000000..c5548e8 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/server/interceptor/CommonInterceptor.kt @@ -0,0 +1,19 @@ +package com.youngfeng.android.assistant.server.interceptor + +import com.yanzhenjie.andserver.annotation.Interceptor +import com.yanzhenjie.andserver.framework.HandlerInterceptor +import com.yanzhenjie.andserver.framework.handler.RequestHandler +import com.yanzhenjie.andserver.http.HttpRequest +import com.yanzhenjie.andserver.http.HttpResponse + +@Interceptor +class CommonInterceptor : HandlerInterceptor { + override fun onIntercept( + request: HttpRequest, + response: HttpResponse, + handler: RequestHandler + ): Boolean { + response.addHeader("Access-Control-Allow-Private-Network", "true") + return false + } +} From b1b97703ebb65a5c6044ff4e71f5ec7b1c40656c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AC=A7=E9=98=B3=E9=94=8B?= Date: Fri, 2 Dec 2022 00:28:34 +0800 Subject: [PATCH 11/11] Update version to 0.4.0 --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index fc817cf..232c9ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { minSdk 21 targetSdk 29 versionCode 31 - versionName "0.3.1" + versionName "0.4.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" }