diff --git a/app/build.gradle b/app/build.gradle index d70dcde..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" } @@ -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' @@ -112,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 367790e..dad38bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -10,13 +11,19 @@ - - + + + + + + + + android:label="@string/about" /> + - + - CommonUtil.openExternalBrowser( - this, - getString(R.string.url_project_desktop) - ) + startDrawOverlayRequestActivity() } - .setNegativeButton(R.string.refuse) { dialog, _ -> + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() - }.setMessage(R.string.tip_support_developer) + }.setMessage(R.string.rationale_draw_overlay_window) .create() } private val mUninstalledPackages = mutableListOf() @@ -85,6 +85,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") @@ -99,6 +100,7 @@ class MainActivity : AppCompatActivity(), EasyPermissions.PermissionCallbacks { updatePermissionsStatus() requestPermissions(true) + requestFloatPermission() if (!EventBus.getDefault().isRegistered(this)) { EventBus.getDefault().register(this) @@ -140,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 { @@ -235,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 { @@ -273,6 +277,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) @@ -324,6 +346,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 @@ -348,6 +375,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/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/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/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/HttpConst.kt b/app/src/main/java/com/youngfeng/android/assistant/server/HttpConst.kt index 297817b..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), @@ -64,6 +71,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/AudioController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/AudioController.kt index c99564d..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,10 +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 @@ -15,29 +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 @@ -49,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}") @@ -139,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/CommonController.kt b/app/src/main/java/com/youngfeng/android/assistant/server/controller/CommonController.kt index 877409a..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 @@ -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 @@ -16,11 +17,14 @@ 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 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 @@ -30,6 +34,7 @@ import timber.log.Timber import java.io.File import java.util.Locale +@CrossOrigin @RestController @RequestMapping("/common") class CommonController { @@ -58,8 +63,7 @@ class CommonController { index++ val appName = packageManager.getApplicationLabel( packageManager.getApplicationInfo( - it.packageName, - 0 + it.packageName, 0 ) ).toString() val packageInfo = packageManager.getPackageInfo(it.packageName, 0) @@ -125,10 +129,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 } } @@ -165,7 +172,29 @@ 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() } + + @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/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..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 @@ -28,6 +30,7 @@ import java.io.* import java.lang.Exception import java.util.Locale +@CrossOrigin @RestController @RequestMapping("/file") open class FileController { @@ -234,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 d78b481..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,36 +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 @@ -69,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") @@ -233,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/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..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,32 +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 @@ -62,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 @@ -165,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/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 + } +} 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..671e08c --- /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 +) 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/support/DeveloperSupportActivity.kt b/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt new file mode 100644 index 0000000..837fb90 --- /dev/null +++ b/app/src/main/java/com/youngfeng/android/assistant/support/DeveloperSupportActivity.kt @@ -0,0 +1,42 @@ +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 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) + } + mRewardBtn.setOnClickListener { + mRewardDialog.show() + } + } +} 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 86426f2..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 @@ -4,12 +4,20 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.BatteryManager +import android.os.Binder 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 object CommonUtil { @@ -32,9 +40,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) @@ -44,8 +50,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) @@ -63,7 +68,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) } @@ -75,4 +82,107 @@ object CommonUtil { intent.data = Uri.fromParts("package", context.packageName, null) context.startActivity(intent) } + + 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 + } + } + } + + 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/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_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_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_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/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_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_developer_support.xml b/app/src/main/res/layout/activity_developer_support.xml new file mode 100644 index 0000000..0b6cd06 --- /dev/null +++ b/app/src/main/res/layout/activity_developer_support.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + \ 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..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"> + + + + + + + \ 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/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" /> + Cancel Expand Scan + Connect About Scanning QR code needs enable camera permission, please go to setting to enable it. Enable immediately @@ -76,4 +77,29 @@ 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 + 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 + 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-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..5291335 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,7 @@ 取消 展开 扫一扫 + 连接 关于 扫描二维码需要开启摄像头权限,请前往设置页面开启 立即开启 @@ -76,4 +77,29 @@ 上传音频失败 上传视频失败 工作中 -_- + 地址 + 访问控制 + 安全性 + 允许 + 禁止 + + 密码 + 密码应至少包含6位数字 + 设置网页连接 + 网页访问是禁止的,请在手机端"设置网页连接-访问控制"设置"允许" + 密码不正确,请重新输入 + 为了更好地给你提供服务,请允许AirController显示在其它应用的上层 + Star + 选择一种方式支持作者 + 打赏 + 二维码已保存到相册 + 打赏任意金额,支持作者继续开发,谢谢您的支持! + 看广告 + 部分图片删除失败,失败数量:%s + 部分相册删除失败,失败数量:%s + 部分音频删除失败,失败数量:%s + 部分视频删除失败,失败数量:%s + 部分视频文件夹删除失败,失败数量:%s + 删除视频文件夹失败 + 部分文件删除失败,失败数量:%s \ 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