From 323ddf829669575e86a1b5a4c8b19d10ab54180f Mon Sep 17 00:00:00 2001 From: Ashutosh Gupta <171418907+meashutoshhoon@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:44:39 +0530 Subject: [PATCH] feat(ui): migrate design to Material You --- app/build.gradle.kts | 9 +- .../java/com/matanh/transfer/AppFlowTest.kt | 1 - app/src/main/AndroidManifest.xml | 20 +- .../java/com/matanh/transfer/MainActivity.kt | 701 ------------------ .../transfer/server/FileServerService.kt | 2 +- .../com/matanh/transfer/server/KtorServer.kt | 7 +- .../com/matanh/transfer/ui/AboutActivity.kt | 118 --- .../com/matanh/transfer/ui/MainViewModel.kt | 125 ++-- .../matanh/transfer/ui/SettingsActivity.kt | 131 ---- .../com/matanh/transfer/ui/SetupActivity.kt | 104 --- .../ui/activity/aboutus/AboutActivity.kt | 92 +++ .../ui/activity/main/HomeBottomSheet.kt | 104 +++ .../transfer/ui/activity/main/MainActivity.kt | 263 +++++++ .../ui/activity/main/QrcodeBottomSheet.kt | 81 ++ .../activity/main/fragment/ConfigFragment.kt | 386 ++++++++++ .../activity/main/fragment/FilesFragment.kt | 222 ++++++ .../ui/activity/settings/LookAndFeel.kt | 117 +++ .../ui/activity/settings/SettingsActivity.kt | 274 +++++++ .../ui/activity/startup/StartUpActivity.kt | 86 +++ .../transfer/ui/adapter/MainPagerAdapter.kt | 23 + .../matanh/transfer/ui/adapter/TeamAdapter.kt | 51 ++ .../transfer/ui/common/ActivityReloader.kt | 22 + .../matanh/transfer/ui/common/BaseActivity.kt | 145 ++++ .../java/com/matanh/transfer/ui/common/Ext.kt | 14 + .../transfer/ui/common/HapticFeedback.kt | 12 + .../matanh/transfer/ui/common/UiExtensions.kt | 21 + .../transfer/ui/components/InsetsHelper.kt | 64 ++ .../matanh/transfer/ui/items/TeamMember.kt | 7 + .../transfer/ui/views/SettingsCategoryItem.kt | 3 + .../transfer/ui/views/SettingsCategoryView.kt | 36 + .../transfer/ui/views/SettingsItemView.kt | 42 ++ .../ui/views/SettingsSwitchItemView.kt | 61 ++ .../com/matanh/transfer/util/Constants.kt | 7 +- .../com/matanh/transfer/util/ErrorReport.kt | 29 +- .../com/matanh/transfer/util/FileAdapter.kt | 108 ++- .../com/matanh/transfer/util/FileUtils.kt | 151 ++-- .../com/matanh/transfer/util/HapticUtils.kt | 31 + .../matanh/transfer/util/IpEntryAdapter.kt | 40 +- .../matanh/transfer/util/PreferenceUtil.kt | 58 ++ .../matanh/transfer/util/QRCodeGenerator.kt | 73 +- .../com/matanh/transfer/util/ShareHandler.kt | 8 +- .../com/matanh/transfer/util/ThemeUtil.kt | 54 ++ .../com/matanh/transfer/util/TransferApp.kt | 49 +- .../res/drawable-night/ic_onboarding3.xml | 185 +++++ app/src/main/res/drawable/app_badging.xml | 10 + app/src/main/res/drawable/colorize.xml | 10 + app/src/main/res/drawable/contrast.xml | 10 + app/src/main/res/drawable/cookie_shape.xml | 9 + app/src/main/res/drawable/folder.xml | 10 + app/src/main/res/drawable/group.xml | 10 + app/src/main/res/drawable/help.xml | 11 + app/src/main/res/drawable/ic_bmc_logo.xml | 21 + app/src/main/res/drawable/ic_check.xml | 11 + app/src/main/res/drawable/ic_cross.xml | 11 + app/src/main/res/drawable/ic_github.xml | 11 + app/src/main/res/drawable/ic_onboarding3.xml | 183 +++++ app/src/main/res/drawable/ic_telegram.xml | 10 + app/src/main/res/drawable/ic_undraw_theme.xml | 109 +++ app/src/main/res/drawable/info.xml | 10 + app/src/main/res/drawable/mobile_vibrate.xml | 10 + app/src/main/res/drawable/open_in_new.xml | 11 + app/src/main/res/drawable/palette.xml | 10 + app/src/main/res/drawable/password.xml | 10 + app/src/main/res/drawable/sync.xml | 10 + app/src/main/res/drawable/thumb_drawable.xml | 13 + app/src/main/res/drawable/ui_shape1.xml | 12 + app/src/main/res/drawable/ui_shape2.xml | 14 + app/src/main/res/drawable/ui_shape3.xml | 14 + app/src/main/res/drawable/ui_shape4.xml | 21 + app/src/main/res/drawable/ui_shape5.xml | 21 + app/src/main/res/font/hanken_grotesk_bold.ttf | Bin 0 -> 66508 bytes app/src/main/res/layout/activity_about.xml | 161 ---- app/src/main/res/layout/activity_about_us.xml | 284 +++++++ .../res/layout/activity_look_and_feel.xml | 471 ++++++++++++ app/src/main/res/layout/activity_main.xml | 172 ----- app/src/main/res/layout/activity_main_new.xml | 153 ++++ .../main/res/layout/activity_report_error.xml | 41 +- app/src/main/res/layout/activity_settings.xml | 60 +- app/src/main/res/layout/activity_setup.xml | 61 -- app/src/main/res/layout/activity_start_up.xml | 36 + app/src/main/res/layout/fragment_config.xml | 176 +++++ app/src/main/res/layout/fragment_files.xml | 36 + app/src/main/res/layout/fragment_layout.xml | 129 ++++ app/src/main/res/layout/item_file.xml | 170 +++-- .../main/res/layout/layout_bottom_sheet.xml | 183 +++++ .../main/res/layout/list_item_team_member.xml | 47 ++ app/src/main/res/layout/qr_bottom_sheet.xml | 107 +++ .../res/layout/settings_category_item.xml | 36 + app/src/main/res/layout/settings_item.xml | 69 ++ .../main/res/layout/settings_switch_item.xml | 81 ++ .../main/res/menu/contextual_action_menu.xml | 12 +- app/src/main/res/menu/file_item_menu.xml | 12 +- app/src/main/res/menu/main_menu.xml | 32 - app/src/main/res/menu/menu_main.xml | 10 - app/src/main/res/values-land/dimens.xml | 3 - app/src/main/res/values-night-v31/themes.xml | 104 +++ app/src/main/res/values-night/colors.xml | 24 - app/src/main/res/values-night/styles.xml | 18 + app/src/main/res/values-night/themes.xml | 32 - app/src/main/res/values-v31/themes.xml | 53 ++ app/src/main/res/values-w1240dp/dimens.xml | 3 - app/src/main/res/values-w600dp/dimens.xml | 3 - app/src/main/res/values/attrs.xml | 8 + app/src/main/res/values/colors.xml | 48 +- app/src/main/res/values/dimens.xml | 3 - app/src/main/res/values/strings.xml | 26 + app/src/main/res/values/styles.xml | 144 ++++ app/src/main/res/values/themes.xml | 47 -- app/src/main/res/xml/preferences.xml | 26 - gradle/libs.versions.toml | 24 +- settings.gradle.kts | 1 + 111 files changed, 5757 insertions(+), 2068 deletions(-) delete mode 100644 app/src/main/java/com/matanh/transfer/MainActivity.kt delete mode 100644 app/src/main/java/com/matanh/transfer/ui/AboutActivity.kt delete mode 100644 app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt delete mode 100644 app/src/main/java/com/matanh/transfer/ui/SetupActivity.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/aboutus/AboutActivity.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/main/HomeBottomSheet.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/main/MainActivity.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/main/QrcodeBottomSheet.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/ConfigFragment.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/FilesFragment.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/settings/LookAndFeel.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/settings/SettingsActivity.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/activity/startup/StartUpActivity.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/adapter/MainPagerAdapter.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/adapter/TeamAdapter.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/common/ActivityReloader.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/common/BaseActivity.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/common/Ext.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/common/HapticFeedback.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/common/UiExtensions.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/components/InsetsHelper.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/items/TeamMember.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryItem.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryView.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/views/SettingsItemView.kt create mode 100644 app/src/main/java/com/matanh/transfer/ui/views/SettingsSwitchItemView.kt create mode 100644 app/src/main/java/com/matanh/transfer/util/HapticUtils.kt create mode 100644 app/src/main/java/com/matanh/transfer/util/PreferenceUtil.kt create mode 100644 app/src/main/java/com/matanh/transfer/util/ThemeUtil.kt create mode 100644 app/src/main/res/drawable-night/ic_onboarding3.xml create mode 100644 app/src/main/res/drawable/app_badging.xml create mode 100644 app/src/main/res/drawable/colorize.xml create mode 100644 app/src/main/res/drawable/contrast.xml create mode 100644 app/src/main/res/drawable/cookie_shape.xml create mode 100644 app/src/main/res/drawable/folder.xml create mode 100644 app/src/main/res/drawable/group.xml create mode 100644 app/src/main/res/drawable/help.xml create mode 100644 app/src/main/res/drawable/ic_bmc_logo.xml create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/drawable/ic_cross.xml create mode 100644 app/src/main/res/drawable/ic_github.xml create mode 100644 app/src/main/res/drawable/ic_onboarding3.xml create mode 100644 app/src/main/res/drawable/ic_telegram.xml create mode 100644 app/src/main/res/drawable/ic_undraw_theme.xml create mode 100644 app/src/main/res/drawable/info.xml create mode 100644 app/src/main/res/drawable/mobile_vibrate.xml create mode 100644 app/src/main/res/drawable/open_in_new.xml create mode 100644 app/src/main/res/drawable/palette.xml create mode 100644 app/src/main/res/drawable/password.xml create mode 100644 app/src/main/res/drawable/sync.xml create mode 100644 app/src/main/res/drawable/thumb_drawable.xml create mode 100644 app/src/main/res/drawable/ui_shape1.xml create mode 100644 app/src/main/res/drawable/ui_shape2.xml create mode 100644 app/src/main/res/drawable/ui_shape3.xml create mode 100644 app/src/main/res/drawable/ui_shape4.xml create mode 100644 app/src/main/res/drawable/ui_shape5.xml create mode 100644 app/src/main/res/font/hanken_grotesk_bold.ttf delete mode 100644 app/src/main/res/layout/activity_about.xml create mode 100644 app/src/main/res/layout/activity_about_us.xml create mode 100644 app/src/main/res/layout/activity_look_and_feel.xml delete mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_main_new.xml delete mode 100644 app/src/main/res/layout/activity_setup.xml create mode 100644 app/src/main/res/layout/activity_start_up.xml create mode 100644 app/src/main/res/layout/fragment_config.xml create mode 100644 app/src/main/res/layout/fragment_files.xml create mode 100644 app/src/main/res/layout/fragment_layout.xml create mode 100644 app/src/main/res/layout/layout_bottom_sheet.xml create mode 100644 app/src/main/res/layout/list_item_team_member.xml create mode 100644 app/src/main/res/layout/qr_bottom_sheet.xml create mode 100644 app/src/main/res/layout/settings_category_item.xml create mode 100644 app/src/main/res/layout/settings_item.xml create mode 100644 app/src/main/res/layout/settings_switch_item.xml delete mode 100644 app/src/main/res/menu/main_menu.xml delete mode 100644 app/src/main/res/menu/menu_main.xml delete mode 100644 app/src/main/res/values-land/dimens.xml create mode 100644 app/src/main/res/values-night-v31/themes.xml delete mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/styles.xml delete mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values-v31/themes.xml delete mode 100644 app/src/main/res/values-w1240dp/dimens.xml delete mode 100644 app/src/main/res/values-w600dp/dimens.xml create mode 100644 app/src/main/res/values/attrs.xml delete mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/styles.xml delete mode 100644 app/src/main/res/values/themes.xml delete mode 100644 app/src/main/res/xml/preferences.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9093776..bde9e71 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -105,8 +105,11 @@ dependencies { androidTestImplementation (libs.awaitility.kotlin) - // QR Code generation - implementation(libs.zxing.core) - implementation(libs.zxing.android.embedded) + // new added libraries + implementation(libs.mmkv) + implementation(libs.glide) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.qr.generator) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/matanh/transfer/AppFlowTest.kt b/app/src/androidTest/java/com/matanh/transfer/AppFlowTest.kt index 8707e9a..bdf4a06 100644 --- a/app/src/androidTest/java/com/matanh/transfer/AppFlowTest.kt +++ b/app/src/androidTest/java/com/matanh/transfer/AppFlowTest.kt @@ -18,7 +18,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.* -import com.matanh.transfer.ui.SetupActivity import com.matanh.transfer.util.Constants import com.matanh.transfer.util.FileAdapter import kotlinx.serialization.json.Json diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aabea47..64293ec 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,16 +13,17 @@ @@ -31,7 +32,7 @@ @@ -58,15 +59,18 @@ + android:parentActivityName=".ui.activity.main.MainActivity" /> + android:parentActivityName=".ui.activity.settings.SettingsActivity" /> + - - private lateinit var btnCopyIp: ImageButton - private lateinit var rvFiles: RecyclerView - private lateinit var fileAdapter: FileAdapter - private lateinit var fabUpload: FloatingActionButton - private lateinit var viewStatusIndicator: View - private lateinit var tvNoFilesMessage: TextView - private lateinit var btnStartServer: Button - private lateinit var btnStopServer: ImageButton - private lateinit var shareHandler: ShareHandler - - private var fileServerService: FileServerService? = null - private var isServiceBound = false - private var currentSelectedFolderUri: Uri? = null - private val ipPermissionDialogs = mutableMapOf() - - private var actionMode: ActionMode? = null - private val logger = Timber.tag("MainActivity") - - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - val binder = service as FileServerService.LocalBinder - fileServerService = binder.getService() - isServiceBound = true - fileServerService?.activityResumed() // Notify service that UI is active and ready - observeServerState() - observeIpPermissionRequests() - observePullRefresh() - } - - override fun onServiceDisconnected(name: ComponentName?) { - fileServerService = null - isServiceBound = false - } - } - - private val uploadFileLauncher = - registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - uri?.let { sourceUri -> - currentSelectedFolderUri?.let { folder -> - val fileName = FileUtils.getFileName(this, sourceUri) ?: "upload.txt" - // Call the suspend copy function from a coroutine - lifecycleScope.launch { - val copiedFile = FileUtils.copyUriToAppDir( - this@MainActivity, sourceUri, folder, fileName - ) - if (copiedFile != null && copiedFile.exists()) { - Toast.makeText( - this@MainActivity, - getString(R.string.file_uploaded, copiedFile.name), - Toast.LENGTH_SHORT - ).show() - viewModel.loadFiles(folder) - } else { - Toast.makeText( - this@MainActivity, - getString(R.string.file_upload_failed), - Toast.LENGTH_SHORT - ).show() - } - } - } ?: Toast.makeText( - this, getString(R.string.shared_folder_not_selected), Toast.LENGTH_SHORT - ).show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - val toolbar: Toolbar = findViewById(R.id.toolbar) - setSupportActionBar(toolbar) - supportActionBar?.title = getString(R.string.app_name) - - // Initialize ViewModel and ShareHandler - viewModel = ViewModelProvider(this)[MainViewModel::class.java] - shareHandler = ShareHandler(this, viewModel) - - initViews() - setupClickListeners() - setupFileListAndObservers() - - // Observe the shared folder URI from the ViewModel - viewModel.selectedFolderUri.observe(this) { uri -> - currentSelectedFolderUri = uri - if (uri != null) { - // If URI is valid, ensure the server is started with it - startFileServer(uri) - lifecycleScope.launch { - shareHandler.handleIntent(intent, currentSelectedFolderUri)} - } else { - // If no URI is set, guide the user to settings - navigateToSettingsWithMessage(getString(R.string.select_shared_folder_prompt)) - } - } - - // Bind to the FileServerService - Intent(this, FileServerService::class.java).also { intent -> - bindService(intent, serviceConnection, BIND_AUTO_CREATE) - } - - } - - private fun navigateToSettingsWithMessage(message: String) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - startActivity(Intent(this, SettingsActivity::class.java)) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - // Handle intents that arrive while the activity is already running - lifecycleScope.launch { - shareHandler.handleIntent(intent, currentSelectedFolderUri) - } - } - - private fun initViews() { - tvServerStatus = findViewById(R.id.tvServerStatus) - tilIps = findViewById(R.id.tilIps) - actvIps = findViewById(R.id.actvIps) - btnCopyIp = findViewById(R.id.btnCopyIp) - btnStopServer = findViewById(R.id.btnStopServer) - rvFiles = findViewById(R.id.rvFiles) - fabUpload = findViewById(R.id.fabUpload) - viewStatusIndicator = findViewById(R.id.viewStatusIndicator) - tvNoFilesMessage = findViewById(R.id.tvNoFilesMessage) - btnStartServer = findViewById(R.id.btnStartServer) - ipsAdapter = IpEntryAdapter(this) - - actvIps.setAdapter(ipsAdapter) - - } - - private fun getIpURL(): String? { - fun noIp(): T? { - Toast.makeText(this, getString(R.string.no_ip_available), Toast.LENGTH_SHORT) - .show();return null - } - - val display = actvIps.text?.toString() ?: return noIp(); - val preRaw = display.substringBefore(":").trim() - if (preRaw.lowercase() == "error") return noIp(); - - val raw = display.substringAfter(": ").trim() - if (raw.isEmpty()) return noIp(); - return "http://${raw}"; - - val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("IP", raw)) - Toast.makeText(this, R.string.ip_copied_to_clipboard, Toast.LENGTH_SHORT).show() - - } - - private fun setupClickListeners() { - btnCopyIp.setOnClickListener { - val url = getIpURL() ?: return@setOnClickListener - - val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("IP", url)) - Toast.makeText(this, R.string.ip_copied_to_clipboard, Toast.LENGTH_SHORT).show() - } - - fabUpload.setOnClickListener { uploadFileLauncher.launch("*/*") } - btnStartServer.setOnClickListener { - currentSelectedFolderUri?.let { startFileServer(it) } - ?: navigateToSettingsWithMessage(getString(R.string.select_shared_folder_prompt)) - } - btnStopServer.setOnClickListener { - Intent(this, FileServerService::class.java).also { intent -> - intent.action = Constants.ACTION_STOP_SERVICE - startService(intent) - } - } - } - - private fun openWithFile(fileItem: FileItem?) { - if (fileItem == null) { - Toast.makeText(this, getString(R.string.file_not_found), Toast.LENGTH_SHORT).show() - return - } - val documentFile = DocumentFile.fromSingleUri(this, fileItem.uri) ?: run { - Toast.makeText(this, getString(R.string.file_not_found), Toast.LENGTH_SHORT).show() - return - } - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(documentFile.uri, documentFile.type) - // Grant temporary read permission to the receiving app - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - // open the file - try { - val chooserIntent = Intent.createChooser(intent, getString(R.string.open_with_title)) - startActivity(chooserIntent) - } catch (e: Exception) { - logger.e(e, "cannot open file %s", fileItem.name); - // Fallback if no app can open the file type - Toast.makeText(this, getString(R.string.no_app_to_open_file), Toast.LENGTH_SHORT).show() - } - - } - - private fun setupFileListAndObservers() { - fileAdapter = FileAdapter(emptyList(), onItemClick = { _, position -> - if (actionMode != null) { - toggleSelection(position) - } else { - openWithFile(fileAdapter.getFileItem(position)) - // Handle regular item click if needed (e.g., open file preview) - // For now, we can share it as a default action or do nothing - // shareFile(fileItem) // Example: share on single tap when not in CAB mode - } - }, onItemLongClick = { _, position -> - if (actionMode == null) { - startSupportActionMode(actionModeCallback) - } - toggleSelection(position) - true - }) - rvFiles.layoutManager = LinearLayoutManager(this) - rvFiles.adapter = fileAdapter - - // Observe files LiveData from the ViewModel - viewModel.files.observe(this) { files -> - fileAdapter.updateFiles(files) - val isEmpty = files.isEmpty() - tvNoFilesMessage.visibility = if (isEmpty) View.VISIBLE else View.GONE - rvFiles.visibility = if (isEmpty) View.GONE else View.VISIBLE - } - } - - private fun toggleSelection(position: Int) { - fileAdapter.toggleSelection(position) - val count = fileAdapter.getSelectedItemCount() - if (count == 0) { - actionMode?.finish() - } else { - actionMode?.title = getString(R.string.selected_items_count, count) - actionMode?.invalidate() // Refresh CAB menu if needed (e.g. select all state) - } - } - - private val actionModeCallback = object : ActionMode.Callback { - override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { - actionMode = mode - mode?.menuInflater?.inflate(R.menu.contextual_action_menu, menu) - fabUpload.hide() // Hide FAB when action mode is active - return true - } - - override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { - // You can dynamically show/hide menu items here based on selection - menu?.findItem(R.id.action_select_all)?.isVisible = fileAdapter.itemCount > 0 - return true - } - - override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { - val selectedFiles = fileAdapter.getSelectedFileItems() - if (selectedFiles.isEmpty()) { - Toast.makeText( - this@MainActivity, getString(R.string.no_files_selected), Toast.LENGTH_SHORT - ).show() - return false - } - return when (item?.itemId) { - R.id.action_delete_contextual -> { - confirmDeleteMultipleFiles(selectedFiles) - true - } - - R.id.action_share_contextual -> { - shareMultipleFiles(selectedFiles) - true - } - - R.id.action_select_all -> { - fileAdapter.selectAll() - val count = fileAdapter.getSelectedItemCount() - if (count == 0) { // All were deselected - actionMode?.finish() - } else { - actionMode?.title = getString(R.string.selected_items_count, count) - } - true - } - - else -> false - } - } - - override fun onDestroyActionMode(mode: ActionMode?) { - actionMode = null - fileAdapter.clearSelections() - fabUpload.show() // Show FAB again - } - } - - @SuppressLint("SetTextI18n") - private fun observeServerState() { - if (!isServiceBound || fileServerService == null) return - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - fileServerService!!.serverState.collect { state -> - logger.d("Server state changed: $state ") - when (state) { - is ServerState.Starting -> { - tvServerStatus.text = getString(R.string.server_starting) - tvServerStatus.setTextColor( - ContextCompat.getColor( - this@MainActivity, - R.color.colorPrimary - ) - ) - viewStatusIndicator.background = ContextCompat.getDrawable( - this@MainActivity, - R.drawable.status_indicator_running - ) - updateIpDropdown(emptyList(), getString(R.string.server_starting)) - - btnStartServer.visibility = View.GONE - btnStopServer.visibility = View.GONE - btnCopyIp.visibility = View.INVISIBLE - } - - is ServerState.Running -> { - tvServerStatus.text = getString(R.string.server_running) - tvServerStatus.setTextColor( - ContextCompat.getColor( - this@MainActivity, - R.color.green - ) - ) - viewStatusIndicator.background = ContextCompat.getDrawable( - this@MainActivity, - R.drawable.status_indicator_running - ) - val hosts = state.hosts - - val entries = listOfNotNull( - hosts.localIp?.let { IpEntry("WiFi:", "$it:${state.port}") }, - hosts.localHostname?.let { - IpEntry( - "Hostname:", - "$it:${state.port}" - ) - }, - hosts.hotspotIp?.let { IpEntry("Hotspot:", "$it:${state.port}") } - ) - - updateIpDropdown(entries) - - btnStartServer.visibility = View.GONE - btnStopServer.visibility = View.VISIBLE - btnCopyIp.visibility = View.VISIBLE - } - - ServerState.UserStopped, - ServerState.AwaitNetwork -> { - tvServerStatus.text = getString(R.string.server_stopped) - tvServerStatus.setTextColor( - ContextCompat.getColor( - this@MainActivity, - R.color.red - ) - ) - viewStatusIndicator.background = ContextCompat.getDrawable( - this@MainActivity, - R.drawable.status_indicator_stopped - ) - updateIpDropdown(emptyList(), getString(R.string.waiting_for_network)) - btnStartServer.visibility = View.VISIBLE - btnStopServer.visibility = View.GONE - btnCopyIp.visibility = View.INVISIBLE - } - - is ServerState.Error -> { - tvServerStatus.text = - getString(R.string.server_error_format, state.message) - tvServerStatus.setTextColor( - ContextCompat.getColor( - this@MainActivity, - R.color.red - ) - ) - viewStatusIndicator.background = ContextCompat.getDrawable( - this@MainActivity, - R.drawable.status_indicator_stopped - ) - updateIpDropdown( - emptyList(), - getString(R.string.server_error_format, "") - ) - btnStartServer.visibility = View.VISIBLE - btnStopServer.visibility = View.GONE - btnCopyIp.visibility = View.INVISIBLE - } - } - } - } - } - } - - private fun updateIpDropdown(newEntries: List, placeholder: String? = null) { - ipsAdapter.apply { - clear() - addAll(newEntries) - notifyDataSetChanged() - } - // Show either first entry or a placeholder - actvIps.setText( - newEntries.firstOrNull()?.value ?: (placeholder - ?: getString(R.string.waiting_for_network)), - false // don't trigger filtering - ) - // Visibility of copy and QR buttons - btnCopyIp.visibility = if (newEntries.isEmpty()) View.INVISIBLE else View.VISIBLE - } - - - private fun observeIpPermissionRequests() { - if (!isServiceBound || fileServerService == null) return - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - fileServerService!!.ipPermissionRequests.collect { request -> - val ip = request.ipAddress - val deferred = request.deferred - if (ipPermissionDialogs.containsKey(ip) || deferred.isCompleted) return@collect - val dialog = - MaterialAlertDialogBuilder(this@MainActivity).setTitle(getString(R.string.permission_request_title)) - .setMessage(getString(R.string.permission_request_message, ip)) - .setPositiveButton(getString(R.string.allow)) { _, _ -> - deferred.complete( - true - ) - } - .setNegativeButton(getString(R.string.deny)) { _, _ -> - deferred.complete( - false - ) - } - .setOnDismissListener { - if (!deferred.isCompleted) deferred.complete(false) - ipPermissionDialogs.remove(ip) - }.create() - ipPermissionDialogs[ip] = dialog - dialog.show() - } - } - } - } - - private fun observePullRefresh() { - if (!isServiceBound || fileServerService == null) return - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - fileServerService!!.pullRefresh.collect { - currentSelectedFolderUri?.let { viewModel.loadFiles(it) } - } - } - } - } - - private fun startFileServer(folderUri: Uri) { - if (!FileUtils.canWriteToUri(this, folderUri)) { - Toast.makeText(this, getString(R.string.no_write_permission), Toast.LENGTH_LONG).show() - startActivity(Intent(this, SettingsActivity::class.java)) - return - } - val serviceIntent = Intent(this, FileServerService::class.java).apply { - action = Constants.ACTION_START_SERVICE - putExtra(Constants.EXTRA_FOLDER_URI, folderUri.toString()) - } - ContextCompat.startForegroundService(this, serviceIntent) - // 2. (Re)bind so we always have a fresh Binder reference - if (!isServiceBound) { - bindService( - Intent(this, FileServerService::class.java), - serviceConnection, - BIND_AUTO_CREATE - ) - } - } - - private fun showQRCodeDialog(url: String) { - val dialogView = layoutInflater.inflate(R.layout.dialog_qr_code, null) - val ivQRCode = dialogView.findViewById(R.id.ivQRCode) - val tvQRUrl = dialogView.findViewById(R.id.tvQRUrl) - - // Generate QR code - val qrBitmap = QRCodeGenerator.generateQRCode(url, 512) - if (qrBitmap != null) { - ivQRCode.setImageBitmap(qrBitmap) - tvQRUrl.text = url - - MaterialAlertDialogBuilder(this) - .setView(dialogView) - .setPositiveButton(getString(R.string.copy_to_clipboard)) { _, _ -> - val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Server URL", url)) - Toast.makeText(this, R.string.ip_copied_to_clipboard, Toast.LENGTH_SHORT).show() - } - .setNegativeButton(getString(R.string.cancel), null) - .show() - } else { - Toast.makeText(this, R.string.qr_code_generation_failed, Toast.LENGTH_SHORT).show() - } - } - - private fun shareMultipleFiles(files: List) { - if (files.isEmpty()) return - val urisToShare = ArrayList() - files.forEach { fileItem -> - DocumentFile.fromSingleUri(this, fileItem.uri)?.let { docFile -> - if (docFile.canRead()) urisToShare.add(docFile.uri) - } - } - if (urisToShare.isEmpty()) { - Toast.makeText(this, getString(R.string.no_readable_files_share), Toast.LENGTH_SHORT) - .show() - return - } - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND_MULTIPLE - type = "*/*" - putParcelableArrayListExtra(Intent.EXTRA_STREAM, urisToShare) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - try { - startActivity( - Intent.createChooser( - shareIntent, - getString(R.string.share_multiple_files_title, urisToShare.size) - ) - ) - } catch (e: Exception) { - Toast.makeText( - this, - getString(R.string.share_file_error, e.message), - Toast.LENGTH_SHORT - ).show() - } - actionMode?.finish() - } - - private fun confirmDeleteMultipleFiles(files: List) { - if (files.isEmpty()) return - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.confirm_delete_multiple_title, files.size)) - .setMessage(getString(R.string.confirm_delete_multiple_message, files.size)) - .setNegativeButton(getString(R.string.cancel), null) - .setPositiveButton(getString(R.string.delete)) { _, _ -> - viewModel.deleteFiles(files) - actionMode?.finish() - }.show() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.main_menu, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_paste -> { - viewModel.pasteFromClipboard() - true - } - - R.id.action_qr -> { - val url = getIpURL() ?: return true; - showQRCodeDialog(url) - true - - } - - R.id.action_settings -> { - startActivity(Intent(this, SettingsActivity::class.java)) - true - } - - R.id.action_about -> { - startActivity(Intent(this, AboutActivity::class.java)) - true - } - - R.id.action_report_error -> { - ErrorReport().openReport(this) - true - } - - else -> super.onOptionsItemSelected(item) - } - } - - override fun onResume() { - super.onResume() - fileServerService?.activityResumed() - // Let the ViewModel handle reloading the URI from prefs if needed - viewModel.checkSharedFolderUri() - - if (!isServiceBound) { - Intent(this, FileServerService::class.java).also { intent -> - bindService(intent, serviceConnection, BIND_AUTO_CREATE) - } - } - } - - override fun onPause() { - super.onPause() - fileServerService?.activityPaused() // Notify service that UI is no longer in the foreground - } - - override fun onStop() { - super.onStop() - // Unbind from the service when the activity is no longer visible - // to prevent leaks if the service is not a foreground service or is stopped. - // If the service is a long-running foreground service, you might choose to unbind - // but not stop the service here. The service binding is for UI interaction. - if (isServiceBound) { - try { - unbindService(serviceConnection) - } catch (e: IllegalArgumentException) { - logger.e("Service not registered or already unbound: ${e.message}") - } - isServiceBound = false - } - } - - override fun onDestroy() { - super.onDestroy() - // Dismiss any lingering dialogs to prevent window leaks - ipPermissionDialogs.values.forEach { if (it.isShowing) it.dismiss() } - ipPermissionDialogs.clear() - - // If action mode is active, finish it - actionMode?.finish() - } -} diff --git a/app/src/main/java/com/matanh/transfer/server/FileServerService.kt b/app/src/main/java/com/matanh/transfer/server/FileServerService.kt index 3e68e47..b705250 100644 --- a/app/src/main/java/com/matanh/transfer/server/FileServerService.kt +++ b/app/src/main/java/com/matanh/transfer/server/FileServerService.kt @@ -15,8 +15,8 @@ import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import com.matanh.transfer.util.Constants -import com.matanh.transfer.MainActivity import com.matanh.transfer.R +import com.matanh.transfer.ui.activity.main.MainActivity import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer diff --git a/app/src/main/java/com/matanh/transfer/server/KtorServer.kt b/app/src/main/java/com/matanh/transfer/server/KtorServer.kt index 5446c5c..445a5e6 100644 --- a/app/src/main/java/com/matanh/transfer/server/KtorServer.kt +++ b/app/src/main/java/com/matanh/transfer/server/KtorServer.kt @@ -5,6 +5,8 @@ import android.net.Uri import androidx.documentfile.provider.DocumentFile import com.matanh.transfer.util.FileUtils import com.matanh.transfer.R +import com.matanh.transfer.util.FileUtils.generateUniqueFileName +import com.matanh.transfer.util.FileUtils.toReadableFileSize import io.ktor.http.ContentDisposition import io.ktor.http.ContentType import io.ktor.http.Headers @@ -185,8 +187,7 @@ suspend fun handleFileUpload( // 2. Generate a unique filename val nameWithoutExt = sanitizedFileName.substringBeforeLast('.', sanitizedFileName) val extension = sanitizedFileName.substringAfterLast('.', "") - val uniqueFileName = - FileUtils.generateUniqueFileName(baseDocumentFile, nameWithoutExt, extension) + val uniqueFileName = baseDocumentFile.generateUniqueFileName(nameWithoutExt, extension) // Always create with no specific mime (prevent the provider from adding a file extension) val effectiveMimeType = ContentType.Application.OctetStream.toString() @@ -410,7 +411,7 @@ fun Application.ktorServer( FileInfo( name = docFile.name ?: "Unknown", size = docFile.length(), - formattedSize = FileUtils.formatFileSize(docFile.length()), + formattedSize = docFile.length().toReadableFileSize(), lastModified = dateFormat.format(lastModifiedDate), type = docFile.type ?: "unknown", downloadUrl = "/api/download/${ diff --git a/app/src/main/java/com/matanh/transfer/ui/AboutActivity.kt b/app/src/main/java/com/matanh/transfer/ui/AboutActivity.kt deleted file mode 100644 index 6e3fff3..0000000 --- a/app/src/main/java/com/matanh/transfer/ui/AboutActivity.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.matanh.transfer.ui - -import android.content.Intent -import android.graphics.ColorMatrix -import android.graphics.ColorMatrixColorFilter -import android.graphics.Paint -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.View -import android.view.animation.AnimationUtils -import android.widget.TextView -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.core.net.toUri -import com.google.android.material.button.MaterialButton -import com.matanh.transfer.BuildConfig -import com.matanh.transfer.R -import timber.log.Timber - - -class AboutActivity : AppCompatActivity() { - private var clickCount = 0 - private var inverted = false - private val clickTimeout = 3000L // ms - private val handler = Handler(Looper.getMainLooper()) - private val resetClickRunnable = Runnable { clickCount = 0 } - private val logger = Timber.tag("AboutActivity") - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_about) - - // Setup Toolbar - setSupportActionBar(findViewById(R.id.toolbar)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) -// supportActionBar?.title = "" - - // Set Version Name dynamically - val versionTextView: TextView = findViewById(R.id.tvVersion) - val versionName = BuildConfig.VERSION_NAME - versionTextView.text = getString(R.string.version_name, 0, versionName) - - fun updateText() { - versionTextView.text = getString(R.string.version_name, clickCount, versionName) - } - - versionTextView.setOnClickListener { - clickCount++ - handler.removeCallbacks(resetClickRunnable) - if (clickCount == 9) { - toggleInvertColors() - clickCount = 0 - } else { - handler.postDelayed(resetClickRunnable, clickTimeout) - } - updateText() - } - versionTextView.setOnLongClickListener { - val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager - val clip = android.content.ClipData.newPlainText("Version", versionName) - clipboard.setPrimaryClip(clip) - - Toast.makeText(this, "$versionName copied!", Toast.LENGTH_SHORT).show() - clickCount = 0 - updateText() - true - } - - // Fade-in Animation for Card - val cardContent = findViewById(R.id.cardContent) - val fadeIn = AnimationUtils.loadAnimation(this, android.R.anim.fade_in) - cardContent.startAnimation(fadeIn) - - // GitHub Button Click - val btnGithub: MaterialButton = findViewById(R.id.btnGithub) - btnGithub.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, "https://github.com/matan-h/transfer".toUri()) - startActivity(intent) - } - - // Buy Me a Coffee Button Click - val btnCoffee: MaterialButton = findViewById(R.id.btnCoffee) - btnCoffee.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, "https://coff.ee/matanh".toUri()) - startActivity(intent) - } - } - - private fun toggleInvertColors() { - Toast.makeText(this, "flash! ", Toast.LENGTH_SHORT).show() - // Grab the very top view of the window - val rootView: View = window.decorView.rootView - - if (!inverted) { - // Build an invert‐color matrix - val invertMatrix = ColorMatrix( - floatArrayOf( - -1f, 0f, 0f, 0f, 255f, - 0f, -1f, 0f, 0f, 255f, - 0f, 0f, -1f, 0f, 255f, - 0f, 0f, 0f, 1f, 0f - ) - ) - val filter = ColorMatrixColorFilter(invertMatrix) - // Create a paint with that filter - val paint = Paint().apply { colorFilter = filter } - // Put the root view into a hardware layer using our paint - rootView.setLayerType(View.LAYER_TYPE_HARDWARE, paint) - } else { - // Remove the hardware layer (back to normal) - rootView.setLayerType(View.LAYER_TYPE_NONE, null) - } - - inverted = !inverted - } -} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/MainViewModel.kt b/app/src/main/java/com/matanh/transfer/ui/MainViewModel.kt index 41d2f04..f5d1a0f 100644 --- a/app/src/main/java/com/matanh/transfer/ui/MainViewModel.kt +++ b/app/src/main/java/com/matanh/transfer/ui/MainViewModel.kt @@ -1,7 +1,6 @@ package com.matanh.transfer.ui import android.app.Application -import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.net.Uri @@ -28,8 +27,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _selectedFolderUri = MutableLiveData() val selectedFolderUri: LiveData = _selectedFolderUri + private val prefs by lazy { + getApplication().getSharedPreferences(Constants.SHARED_PREFS_NAME, Context.MODE_PRIVATE) + } + init { - // Load the initial URI when the ViewModel is created checkSharedFolderUri() } @@ -38,17 +40,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * This can be called from onResume to detect changes made in other activities. */ fun checkSharedFolderUri() { - val prefs = getApplication().getSharedPreferences(Constants.SHARED_PREFS_NAME, Context.MODE_PRIVATE) - val folderUriString = prefs.getString(Constants.EXTRA_FOLDER_URI, null) - val currentUri = folderUriString?.toUri() - - // Only update if the value is different to avoid unnecessary reloads - if (_selectedFolderUri.value != currentUri) { - _selectedFolderUri.value = currentUri - currentUri?.let { loadFiles(it) } ?: _files.postValue(emptyList()) // Clear files if URI is null - } else if (currentUri != null && _files.value.isNullOrEmpty()) { - // If URI is the same but file list is empty, try loading again - loadFiles(currentUri) + val currentUri = prefs.getString(Constants.EXTRA_FOLDER_URI, null)?.toUri() + + when { + _selectedFolderUri.value != currentUri -> { + _selectedFolderUri.value = currentUri + currentUri?.let { loadFiles(it) } ?: _files.postValue(emptyList()) + } + currentUri != null && _files.value.isNullOrEmpty() -> loadFiles(currentUri) } } @@ -58,23 +57,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { */ fun loadFiles(folderUri: Uri) { viewModelScope.launch(Dispatchers.IO) { - // This is where you would implement your file listing logic. - // Based on your original code, it seems you have a FileUtils class for this. - val fileList = mutableListOf() - val parentDocument = DocumentFile.fromTreeUri(getApplication(), folderUri) - parentDocument?.listFiles()?.forEach { docFile -> - fileList.add( + val fileList = DocumentFile.fromTreeUri(getApplication(), folderUri) + ?.listFiles() + ?.map { docFile -> FileItem( name = docFile.name ?: "Unknown", size = docFile.length(), lastModified = docFile.lastModified(), uri = docFile.uri ) - ) - } - withContext(Dispatchers.Main) { - _files.value = fileList - } + } ?: emptyList() + + _files.postValue(fileList) } } @@ -82,29 +76,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * Handles the "paste" action from the menu. */ fun pasteFromClipboard() { - val folderUri = _selectedFolderUri.value - if (folderUri == null) { - Toast.makeText(getApplication(), R.string.shared_folder_not_selected, Toast.LENGTH_SHORT).show() + val folderUri = _selectedFolderUri.value ?: run { + showToast(R.string.shared_folder_not_selected) return } val clipboard = getApplication().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - if (clipboard.hasPrimaryClip() && clipboard.primaryClipDescription?.hasMimeType("text/plain") == true) { - val textToPaste = clipboard.primaryClip?.getItemAt(0)?.text?.toString() - if (!textToPaste.isNullOrEmpty()) { - viewModelScope.launch { - val file = FileUtils.createTextFileInDir(getApplication(), folderUri, "paste", "txt", textToPaste) - if (file != null && file.exists()) { - Toast.makeText(getApplication(), getApplication().getString(R.string.text_pasted_to_file, file.name), Toast.LENGTH_SHORT).show() - loadFiles(folderUri) // Refresh file list + val clipDescription = clipboard.primaryClipDescription + + if (!clipboard.hasPrimaryClip() || clipDescription?.hasMimeType("text/plain") != true) { + showToast(R.string.no_text_in_clipboard) + return + } + + val textToPaste = clipboard.primaryClip?.getItemAt(0)?.text?.toString() + if (textToPaste.isNullOrEmpty()) { + showToast(R.string.clipboard_empty) + return + } + + viewModelScope.launch(Dispatchers.IO) { + val file = FileUtils.createTextFileInDir( + getApplication(), folderUri, "paste", "txt", textToPaste + ) + + withContext(Dispatchers.Main) { + if (file?.exists() == true) { + showToast(R.string.text_pasted_to_file, file.name) + loadFiles(folderUri) } else { - Toast.makeText(getApplication(), R.string.failed_to_paste_text, Toast.LENGTH_SHORT).show() - }} - } else { - Toast.makeText(getApplication(), R.string.clipboard_empty, Toast.LENGTH_SHORT).show() + showToast(R.string.failed_to_paste_text) + } } - } else { - Toast.makeText(getApplication(), R.string.no_text_in_clipboard, Toast.LENGTH_SHORT).show() } } @@ -113,19 +116,45 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { */ fun deleteFiles(filesToDelete: List) { val folderUri = _selectedFolderUri.value ?: return + viewModelScope.launch(Dispatchers.IO) { filesToDelete.forEach { fileItem -> - // Use DocumentFile to delete the file via its URI DocumentFile.fromSingleUri(getApplication(), fileItem.uri)?.delete() } + withContext(Dispatchers.Main) { - Toast.makeText( - getApplication(), - getApplication().getString(R.string.files_deleted_successfully, filesToDelete.size), - Toast.LENGTH_SHORT - ).show() - loadFiles(folderUri) // Refresh file list + showToast(R.string.files_deleted_successfully, filesToDelete.size) + loadFiles(folderUri) } } } + + fun refreshFiles() { + _selectedFolderUri.value?.let { loadFiles(it) } + } + + fun getFileCount(): Int = _files.value?.size ?: 0 + + fun getFolderFileCount(): Int { + val folderUri = _selectedFolderUri.value ?: return 0 + return DocumentFile.fromTreeUri(getApplication(), folderUri) + ?.listFiles() + ?.size ?: 0 + } + + suspend fun getFolderFileCountAsync(): Int = withContext(Dispatchers.IO) { + val folderUri = _selectedFolderUri.value ?: return@withContext 0 + DocumentFile.fromTreeUri(getApplication(), folderUri) + ?.listFiles() + ?.size ?: 0 + } + + private fun showToast(resId: Int, vararg args: Any?) { + Toast.makeText( + getApplication(), + getApplication().getString(resId, *args), + Toast.LENGTH_SHORT + ).show() + } + } diff --git a/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt b/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt deleted file mode 100644 index 036e5cb..0000000 --- a/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.matanh.transfer.ui - -import android.net.Uri -import android.os.Bundle -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.Toolbar -import androidx.core.content.edit -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import androidx.preference.EditTextPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.matanh.transfer.util.Constants -import com.matanh.transfer.util.FileUtils -import com.matanh.transfer.R - - -class SettingsActivity : AppCompatActivity() { - - private val selectFolderLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> - if (uri != null) { - FileUtils.persistUriPermission(this, uri) - val prefs = getSharedPreferences(Constants.SHARED_PREFS_NAME, MODE_PRIVATE) - prefs.edit { putString(Constants.EXTRA_FOLDER_URI, uri.toString()) } - Toast.makeText(this, "Shared folder selected", Toast.LENGTH_SHORT).show() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - } - return super.onOptionsItemSelected(item) - } - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_settings) - val toolbar = findViewById(R.id.toolbar) - setSupportActionBar(toolbar) - if (savedInstanceState == null) { - supportFragmentManager - .beginTransaction() - .replace(R.id.settings_container, SettingsFragment()) - .commit() - } - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - supportActionBar?.title = getString(R.string.title_activity_settings) - } - - fun launchFolderSelection() { - selectFolderLauncher.launch(null) - } - - class SettingsFragment : PreferenceFragmentCompat() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - // Tell PreferenceManager to use the right SharedPreferences file (see #18) - preferenceManager.sharedPreferencesName = Constants.SHARED_PREFS_NAME - setPreferencesFromResource(R.xml.preferences, rootKey) - - // Password Preference - val passwordPreference = - findPreference(getString(R.string.pref_key_server_password)) - updatePasswordSummary(passwordPreference) - passwordPreference?.setOnPreferenceChangeListener { preference, newValue -> - val newPassword = newValue as String? - if (newPassword.isNullOrEmpty()) { - preference.summary = getString(R.string.pref_summary_password_protect_off) - Toast.makeText( - requireContext(), - getString(R.string.password_cleared), - Toast.LENGTH_SHORT - ).show() - } else { - preference.summary = getString(R.string.pref_summary_password_protect_on) - Toast.makeText( - requireContext(), - getString(R.string.password_set), - Toast.LENGTH_SHORT - ).show() - } - true - } - - // Folder Selection Preference - val folderPreference = findPreference("pref_key_shared_folder") - folderPreference?.setOnPreferenceClickListener { - (activity as? SettingsActivity)?.launchFolderSelection() - true - } - updateFolderSummary() - } - - private fun updatePasswordSummary(passwordPreference: EditTextPreference?) { - val prefs = preferenceManager.sharedPreferences - - - val password = prefs - ?.getString(getString(R.string.pref_key_server_password), null) - if (password.isNullOrEmpty()) { - passwordPreference?.summary = getString(R.string.pref_summary_password_protect_off) - } else { - passwordPreference?.summary = getString(R.string.pref_summary_password_protect_on) - } - } - - private fun updateFolderSummary() { - val prefs = preferenceManager.sharedPreferences - val uriString = prefs?.getString(Constants.EXTRA_FOLDER_URI, null) - val folderPreference = findPreference("pref_key_shared_folder") - if (uriString != null) { - val uri = uriString.toUri() - val docFile = DocumentFile.fromTreeUri(requireContext(), uri) - folderPreference?.summary = docFile?.name ?: uri.path - } else { - folderPreference?.summary = getString(R.string.no_folder_selected) - } - } - } - - override fun onSupportNavigateUp(): Boolean { - onBackPressedDispatcher.onBackPressed() - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/SetupActivity.kt b/app/src/main/java/com/matanh/transfer/ui/SetupActivity.kt deleted file mode 100644 index aa139df..0000000 --- a/app/src/main/java/com/matanh/transfer/ui/SetupActivity.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.matanh.transfer.ui - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.widget.Button -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import timber.log.Timber -import androidx.activity.result.contract.ActivityResultContracts.RequestPermission - -import android.Manifest -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import com.matanh.transfer.util.Constants -import com.matanh.transfer.util.FileUtils -import com.matanh.transfer.MainActivity -import com.matanh.transfer.R - - -class SetupActivity : AppCompatActivity() { - private val logger = Timber.tag("SetupActivity") - - private val selectFolderLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> - if (uri != null) { - FileUtils.persistUriPermission(this, uri) // Persist permission - // Store the URI so MainActivity can pick it up - val prefs = getSharedPreferences(Constants.SHARED_PREFS_NAME, MODE_PRIVATE) - prefs.edit { putString(Constants.EXTRA_FOLDER_URI, uri.toString()) } - - Toast.makeText(this, getString(R.string.folder_setup_complete), Toast.LENGTH_SHORT) - .show() - launchMainActivity() - } else { - Toast.makeText( - this, - getString(R.string.folder_selection_cancelled), - Toast.LENGTH_SHORT - ).show() - // User might try again or exit; stay on this activity - } - } - - private val requestNotificationPermissionLauncher = - registerForActivityResult(RequestPermission()) { isGranted: Boolean -> - if (isGranted) { - logger.d("Notification permission granted.") - } else { - logger.w("Notification permission denied.") - Toast.makeText(this, getString(R.string.notification_permission_denied), Toast.LENGTH_SHORT).show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - - val prefs = getSharedPreferences(Constants.SHARED_PREFS_NAME, MODE_PRIVATE) - - val persistedUriString = prefs.getString(Constants.EXTRA_FOLDER_URI, null) - - if (!persistedUriString.isNullOrEmpty()) { - val persistedUri = persistedUriString.toUri() - logger.d("Persisted URI: $persistedUri") - // Check if permissions are still valid for the persisted URI - if (FileUtils.isUriPermissionPersisted(this, persistedUri)) { - // Attempt to access the DocumentFile to further validate - val docFile = DocumentFile.fromTreeUri(this, persistedUri) - if (docFile != null && docFile.canRead()) { - launchMainActivity() - return // Skip setup layout - } else { - // URI persisted but not accessible, clear it and proceed with setup - FileUtils.clearPersistedUri(this) - } - } else { - FileUtils.clearPersistedUri(this) // Persisted but no permissions, clear it. - } - } - - setContentView(R.layout.activity_setup) - val btnChooseFolder: Button = findViewById(R.id.btnChooseFolder) - btnChooseFolder.setOnClickListener { - selectFolderLauncher.launch(null) - } - } - - private fun launchMainActivity() { - logger.d("Launching MainActivity") - val intent = Intent(this, MainActivity::class.java) - startActivity(intent) - finish() // Finish SetupActivity so user can't go back to it - } -} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/aboutus/AboutActivity.kt b/app/src/main/java/com/matanh/transfer/ui/activity/aboutus/AboutActivity.kt new file mode 100644 index 0000000..b9a011e --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/aboutus/AboutActivity.kt @@ -0,0 +1,92 @@ +package com.matanh.transfer.ui.activity.aboutus + +import android.annotation.SuppressLint +import android.content.Intent +import android.view.View.GONE +import android.view.View.VISIBLE +import androidx.core.net.toUri +import androidx.core.widget.NestedScrollView +import androidx.recyclerview.widget.LinearLayoutManager +import com.matanh.transfer.BuildConfig +import com.matanh.transfer.databinding.ActivityAboutUsBinding +import com.matanh.transfer.ui.adapter.TeamAdapter +import com.matanh.transfer.ui.common.BaseActivity +import com.matanh.transfer.ui.items.TeamMember +import com.matanh.transfer.util.Url + +class AboutActivity : BaseActivity(ActivityAboutUsBinding::inflate) { + + private val teamAdapter = TeamAdapter { member -> + openUrl(member.social) + } + + override fun init() { + setupToolbar() + setupVersion() + setupRecyclerView() + } + + override fun initLogic() { + setupClicks() + } + + private fun setupToolbar() { + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { + it.hapticClick() + onBackPressedDispatcher.onBackPressed() + } + + setUpDivider() + } + + @SuppressLint("SetTextI18n") + private fun setupVersion() { + binding.textViewVersion.text = "v${BuildConfig.VERSION_NAME}" + } + + private fun setupRecyclerView() { + binding.teamMembersRecyclerView.apply { + layoutManager = LinearLayoutManager(this@AboutActivity) + adapter = teamAdapter + } + + teamAdapter.submitList( + listOf( + TeamMember( + name = "matan h", + imageUrl = "https://avatars.githubusercontent.com/u/56131718?v=4", + social = "https://github.com/matan-h" + ) + ) + ) + } + + private fun setupClicks() { + binding.layoutChangelog.setOnClickListener { + it.hapticClick() + openUrl(Url.REPO_CHANGE_LOG_URL) + } + binding.tvGithub.setOnClickListener { + it.hapticClick() + openUrl(Url.REPO_URL) + } + binding.tvCoffee.setOnClickListener { + it.hapticClick() + openUrl(Url.DEV_COFFEE_URL) + } + } + + private fun openUrl(url: String) { + if (url.isBlank()) return + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } + + private fun setUpDivider() { + binding.divider.hide() + binding.container.isVerticalScrollBarEnabled = false + binding.container.setOnScrollChangeListener { _: NestedScrollView?, _: Int, scrollY: Int, _: Int, _: Int -> + binding.divider.visibility = if (scrollY > 0) VISIBLE else GONE + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/main/HomeBottomSheet.kt b/app/src/main/java/com/matanh/transfer/ui/activity/main/HomeBottomSheet.kt new file mode 100644 index 0000000..1de3d0c --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/main/HomeBottomSheet.kt @@ -0,0 +1,104 @@ +package com.matanh.transfer.ui.activity.main + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.matanh.transfer.BuildConfig +import com.matanh.transfer.R +import com.matanh.transfer.databinding.LayoutBottomSheetBinding +import com.matanh.transfer.ui.activity.aboutus.AboutActivity +import com.matanh.transfer.ui.activity.settings.SettingsActivity +import com.matanh.transfer.ui.common.resolveColorAttr +import com.matanh.transfer.ui.common.setBottomMarginDp +import com.matanh.transfer.util.ErrorReport +import com.matanh.transfer.util.HapticUtils + +class HomeBottomSheet : BottomSheetDialogFragment() { + + private var _binding: LayoutBottomSheetBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = LayoutBottomSheetBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.userDescription.text = getString(R.string.version_format, BuildConfig.VERSION_NAME) + + setupActions() + setupCard() + binding.views.post { binding.views.setBottomMarginDp(10) } + } + + private fun setupActions() { + setupAction(binding.actionSettings) { + startActivity(Intent(requireActivity(), SettingsActivity::class.java)) + } + + setupAction(binding.actionAbout) { + startActivity(Intent(requireActivity(), AboutActivity::class.java)) + } + + setupAction(binding.actionHelp) { + ErrorReport().openReport(requireContext()) + } + } + + private fun setupAction(view: View, action: () -> Unit) { + view.setOnClickListener { + it.isEnabled = false + HapticUtils.weakVibrate(it) + action() + dismiss() + } + } + + private fun setupCard() { + val color = requireContext().resolveColorAttr( + if (requireActivity().isNightMode()) { + com.google.android.material.R.attr.colorSurfaceContainerHighest + } else { + com.google.android.material.R.attr.colorSurfaceContainerLow + } + ) + binding.card.setCardBackgroundColor(color) + } + + override fun onStart() { + super.onStart() + + val dialog = dialog as? BottomSheetDialog ?: return + val bottomSheet = dialog.findViewById( + com.google.android.material.R.id.design_bottom_sheet + ) ?: return + + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + fun Context.isNightMode(): Boolean { + val nightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return nightMode == Configuration.UI_MODE_NIGHT_YES + } + + override fun getTheme(): Int { + return R.style.ThemeOverlay_Ui3_BottomSheetDialog + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/main/MainActivity.kt b/app/src/main/java/com/matanh/transfer/ui/activity/main/MainActivity.kt new file mode 100644 index 0000000..fe95f50 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/main/MainActivity.kt @@ -0,0 +1,263 @@ +package com.matanh.transfer.ui.activity.main + +import android.content.Intent +import android.graphics.Outline +import android.net.Uri +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnLayout +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayoutMediator +import com.matanh.transfer.R +import com.matanh.transfer.databinding.ActivityMainNewBinding +import com.matanh.transfer.ui.MainViewModel +import com.matanh.transfer.ui.activity.settings.SettingsActivity +import com.matanh.transfer.ui.adapter.MainPagerAdapter +import com.matanh.transfer.ui.common.BaseActivity +import com.matanh.transfer.ui.components.InsetsHelper +import com.matanh.transfer.util.FileUtils +import com.matanh.transfer.util.FileUtils.getFileName +import com.matanh.transfer.util.ShareHandler +import kotlinx.coroutines.launch +import kotlin.math.abs + +class MainActivity : BaseActivity(ActivityMainNewBinding::inflate) { + + private val viewModel: MainViewModel by lazy { + ViewModelProvider(this)[MainViewModel::class.java] + } + private lateinit var shareHandler: ShareHandler + private var filesToolbarHeight: Int = 0 + private var toolbarBottomMargin: Int = 0 + private var isPagerScrolling = false + + fun showFAB() { + binding.addTutorialFab.show() + binding.topToolbar.show() + } + + fun hideFAB() { + binding.addTutorialFab.hide() + binding.topToolbar.hide() + } + + private val pageCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + isPagerScrolling = state in listOf( + ViewPager2.SCROLL_STATE_DRAGGING, ViewPager2.SCROLL_STATE_SETTLING + ) + } + + override fun onPageScrolled(position: Int, offset: Float, positionOffsetPixels: Int) { + if (isPagerScrolling) updateProjectsToolbar(position, offset) + } + + override fun onPageSelected(position: Int) { + updateFabState(position) + } + } + + private val uploadFileLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri ?: return@registerForActivityResult + handleFileUpload(uri) + } + + override fun init() { + shareHandler = ShareHandler(this, viewModel) + + updateHeaderPadding() + ensureProjectsToolbarHeight() + setupViewPagerAndTabs() + setupLayoutHelpers() + setupViews() + } + + override fun initLogic() { + viewModel.selectedFolderUri.observe(this) { uri -> + if (uri != null) { + lifecycleScope.launch { + shareHandler.handleIntent(intent, uri) + } + } else { + navigateToSettingsWithMessage(R.string.select_shared_folder_prompt) + } + } + + viewModel.files.observe(this) { + binding.projectsCount.text = getString(R.string.files_count, viewModel.getFileCount()) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + lifecycleScope.launch { + viewModel.selectedFolderUri.value?.let { + shareHandler.handleIntent(intent, it) + } + } + } + + private fun handleFileUpload(sourceUri: Uri) { + val folderUri = viewModel.selectedFolderUri.value ?: run { + toast(getString(R.string.shared_folder_not_selected)) + return + } + + lifecycleScope.launch { + val fileName = getFileName(sourceUri) + val copiedFile = FileUtils.copyUriToAppDir( + this@MainActivity, sourceUri, folderUri, fileName + ) + + if (copiedFile?.exists() == true) { + toast(getString(R.string.file_uploaded, copiedFile.name)) + viewModel.loadFiles(folderUri) + } else { + toast(getString(R.string.file_upload_failed)) + } + } + } + + private fun setupViews() = with(binding) { + iconUserAvatar.setOnClickListener { + it.hapticClick() + showUserBottomSheet() + } + + addTutorialFab.setOnClickListener { + it.hapticClick() + uploadFileLauncher.launch("*/*") + } + } + + private fun showUserBottomSheet() { + val tag = HomeBottomSheet::class.java.simpleName + if (!supportFragmentManager.isStateSaved && supportFragmentManager.findFragmentByTag(tag) == null) { + HomeBottomSheet().show(supportFragmentManager, tag) + } + } + + private fun setupViewPagerAndTabs() { + binding.viewPager.apply { + adapter = MainPagerAdapter(this@MainActivity) + registerOnPageChangeCallback(pageCallback) + } + + TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> + tab.text = when (position) { + 0 -> "CONFIG" + 1 -> "FILES" + else -> "" + } + }.attach() + } + + private fun updateProjectsToolbar(position: Int, offset: Float) { + if (filesToolbarHeight <= 0 || position != 0) return + + val progress = offset.coerceIn(0f, 1f) + + binding.projectsToolbar.apply { + visibility = View.VISIBLE + alpha = progress + + (layoutParams as ViewGroup.MarginLayoutParams).apply { + height = (filesToolbarHeight * progress).toInt() + bottomMargin = (toolbarBottomMargin * progress).toInt() + }.also { layoutParams = it } + } + } + + private fun updateFabState(position: Int) { + when (position) { + 0 -> { + binding.addTutorialFab.hide() + setToolbarState(alpha = 0f, height = 0, margin = 0) + } + + 1 -> { + binding.addTutorialFab.show() + setToolbarState( + alpha = 1f, + height = filesToolbarHeight, + margin = toolbarBottomMargin, + visible = true + ) + } + } + } + + private fun setToolbarState( + alpha: Float, height: Int, margin: Int, visible: Boolean = false + ) { + binding.projectsToolbar.apply { + if (visible) visibility = View.VISIBLE + this.alpha = alpha + + (layoutParams as ViewGroup.MarginLayoutParams).apply { + this.height = height + bottomMargin = margin + }.also { layoutParams = it } + } + } + + private fun setupLayoutHelpers() = with(binding) { + val unselectedColor = getThemeColor( + if (isNightMode()) com.google.android.material.R.attr.colorSurfaceVariant + else com.google.android.material.R.attr.colorOnSurfaceVariant + ) + + tabLayout.setTabTextColors( + unselectedColor, getThemeColor(com.google.android.material.R.attr.colorOnSurface) + ) + + appBarLayout.addOnOffsetChangedListener { appBar, offset -> + headerView.alpha = 1f - abs(offset.toFloat() / appBar.totalScrollRange) + } + + InsetsHelper.applyMargin(addTutorialFab, bottom = true) + setupFrontLayoutOutline() + } + + private fun setupFrontLayoutOutline() { + val radius = (24 * resources.displayMetrics.density).toInt() + + binding.frontLayout.apply { + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect( + 0, 0, view.width, view.height + radius, radius.toFloat() + ) + } + } + clipToOutline = true + } + } + + private fun ensureProjectsToolbarHeight() { + binding.projectsToolbar.doOnLayout { + filesToolbarHeight = it.measuredHeight + toolbarBottomMargin = (it.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin + } + } + + private fun updateHeaderPadding() { + ViewCompat.setOnApplyWindowInsetsListener(binding.main) { _, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.statusBars()) + binding.headerView.setPadding(0, systemBars.top, 0, 0) + insets + } + } + + private fun navigateToSettingsWithMessage(resId: Int) { + toast(getString(resId), true) + openActivity() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/main/QrcodeBottomSheet.kt b/app/src/main/java/com/matanh/transfer/ui/activity/main/QrcodeBottomSheet.kt new file mode 100644 index 0000000..abd78af --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/main/QrcodeBottomSheet.kt @@ -0,0 +1,81 @@ +package com.matanh.transfer.ui.activity.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.matanh.transfer.R +import com.matanh.transfer.databinding.QrBottomSheetBinding +import com.matanh.transfer.ui.common.setBottomMarginDp +import com.matanh.transfer.util.HapticUtils +import com.matanh.transfer.util.QRCodeGenerator + +class QrcodeBottomSheet : BottomSheetDialogFragment() { + + private var _binding: QrBottomSheetBinding? = null + private val binding get() = _binding!! + + private val qrUrl: String + get() = requireArguments().getString(ARG_URL).orEmpty() + + companion object { + private const val ARG_URL = "arg_url" + fun newInstance(url: String) = QrcodeBottomSheet().apply { + arguments = bundleOf(ARG_URL to url) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = QrBottomSheetBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupActions() + setupQr() + + binding.views.post { + binding.views.setBottomMarginDp(10) + } + } + + private fun setupActions() { + binding.back.setOnClickListener { + HapticUtils.weakVibrate(it) + dismiss() + } + } + + private fun setupQr() { + if (qrUrl.isBlank()) return + binding.apply { + tvQRUrl.text = qrUrl + ivQRCode.setImageDrawable( + QRCodeGenerator.generateQRCode(requireContext(), qrUrl) + ) + } + } + + override fun onStart() { + super.onStart() + (dialog as? BottomSheetDialog)?.findViewById(com.google.android.material.R.id.design_bottom_sheet) + ?.let { sheet -> + BottomSheetBehavior.from(sheet).state = BottomSheetBehavior.STATE_EXPANDED + } + } + + override fun getTheme() = R.style.ThemeOverlay_Ui3_BottomSheetDialog + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/ConfigFragment.kt b/app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/ConfigFragment.kt new file mode 100644 index 0000000..c64be92 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/ConfigFragment.kt @@ -0,0 +1,386 @@ +package com.matanh.transfer.ui.activity.main.fragment + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ComponentName +import android.content.Context.BIND_AUTO_CREATE +import android.content.Context.CLIPBOARD_SERVICE +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.IBinder +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.matanh.transfer.R +import com.matanh.transfer.databinding.FragmentConfigBinding +import com.matanh.transfer.server.FileServerService +import com.matanh.transfer.server.IpPermissionRequest +import com.matanh.transfer.server.ServerState +import com.matanh.transfer.ui.MainViewModel +import com.matanh.transfer.ui.activity.main.QrcodeBottomSheet +import com.matanh.transfer.ui.activity.settings.SettingsActivity +import com.matanh.transfer.util.Constants +import com.matanh.transfer.util.FileUtils.canWrite +import com.matanh.transfer.util.HapticUtils +import com.matanh.transfer.util.IpEntry +import com.matanh.transfer.util.IpEntryAdapter +import com.matanh.transfer.util.ShareHandler +import kotlinx.coroutines.launch +import timber.log.Timber + +class ConfigFragment : Fragment() { + + private var _binding: FragmentConfigBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + private lateinit var ipsAdapter: ArrayAdapter + private lateinit var shareHandler: ShareHandler + private var fileServerService: FileServerService? = null + private var isServiceBound = false + private val ipPermissionDialogs = mutableMapOf() + private val logger = Timber.tag("ConfigFragment") + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + fileServerService = (service as FileServerService.LocalBinder).getService() + isServiceBound = true + fileServerService?.activityResumed() + observeServerState() + observeIpPermissionRequests() + observePullRefresh() + } + + override fun onServiceDisconnected(name: ComponentName?) { + fileServerService = null + isServiceBound = false + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ) = FragmentConfigBinding.inflate(inflater, container, false).also { _binding = it }.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + shareHandler = ShareHandler(requireContext(), viewModel) + + setupIpAdapter() + setupClickListeners() + observeViewModel() + bindService() + } + + private fun setupIpAdapter() { + ipsAdapter = IpEntryAdapter(requireContext()) + binding.actvIps.setAdapter(ipsAdapter) + } + + private fun observeViewModel() { + viewModel.selectedFolderUri.observe(viewLifecycleOwner) { uri -> + if (uri != null) { + startFileServer(uri) + lifecycleScope.launch { + shareHandler.handleIntent(requireActivity().intent, uri) + } + } else { + navigateToSettingsWithMessage(R.string.select_shared_folder_prompt) + } + } + } + + private fun setupClickListeners() = with(binding) { + btnShowQr.setOnClickListener { + HapticUtils.weakVibrate(it) + getIpURL()?.let { url -> showQrBottomSheet(url) } + } + + btnCopyIp.setOnClickListener { + HapticUtils.weakVibrate(it) + getIpURL()?.let { url -> + (requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip( + ClipData.newPlainText("IP", url) + ) + showToast(R.string.ip_copied_to_clipboard) + } + } + + btnStartServer.setOnClickListener { view -> + HapticUtils.weakVibrate(view) + toggleServer() + } + } + + private fun toggleServer() { + val isRunning = binding.btnStartServer.text == getString(R.string.stop_server) + + if (isRunning) { + stopFileServer() + } else { + viewModel.selectedFolderUri.value?.let { startFileServer(it) } + ?: navigateToSettingsWithMessage(R.string.select_shared_folder_prompt) + } + } + + private fun stopFileServer() { + Intent(requireActivity(), FileServerService::class.java).apply { + action = Constants.ACTION_STOP_SERVICE + }.also { requireActivity().startService(it) } + } + + private fun observeServerState() { + fileServerService ?: return + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + fileServerService!!.serverState.collect { state -> + logger.d("Server state changed: $state") + updateUIForState(state) + } + } + } + } + + private fun updateUIForState(state: ServerState) = with(binding) { + when (state) { + is ServerState.Starting -> { + setServerStatus( + R.string.server_starting, + Color.RED, + R.drawable.status_indicator_running + ) + updateIpDropdown(emptyList(), getString(R.string.server_starting)) + btnStartServer.apply { + isEnabled = false + text = getString(R.string.stop_server) + } + btnCopyIp.isVisible = false + } + + is ServerState.Running -> { + binding.status.apply { + text = getString(R.string.server_running) + setTextColor( + MaterialColors.getColor( + requireActivity(), + android.R.attr.colorPrimary, + Color.GREEN + ) + ) + } + binding.viewStatusIndicator.background = + ContextCompat.getDrawable(requireContext(), R.drawable.status_indicator_running) + val entries = buildIpEntries(state) + updateIpDropdown(entries) + btnStartServer.apply { + isEnabled = true + text = getString(R.string.stop_server) + } + btnCopyIp.isVisible = true + } + + ServerState.UserStopped, ServerState.AwaitNetwork -> { + setServerStatus( + R.string.server_stopped, + Color.RED, + R.drawable.status_indicator_stopped + ) + updateIpDropdown(emptyList(), getString(R.string.waiting_for_network)) + btnStartServer.apply { + isEnabled = true + text = getString(R.string.start_server) + } + btnCopyIp.isVisible = false + } + + is ServerState.Error -> { + setServerStatus( + R.string.server_error_format, + Color.RED, + R.drawable.status_indicator_stopped, + state.message + ) + updateIpDropdown(emptyList(), getString(R.string.server_error_format, "")) + btnStartServer.apply { + isEnabled = true + text = getString(R.string.stop_server) + } + btnCopyIp.isVisible = false + } + } + } + + private fun setServerStatus(textResId: Int, color: Int, drawableResId: Int, vararg args: Any?) { + binding.status.apply { + text = getString(textResId, *args) + setTextColor( + MaterialColors.getColor( + requireActivity(), + android.R.attr.colorError, + color + ) + ) + } + binding.viewStatusIndicator.background = + ContextCompat.getDrawable(requireContext(), drawableResId) + } + + private fun buildIpEntries(state: ServerState.Running): List { + return listOfNotNull( + state.hosts.localIp?.let { IpEntry("WiFi:", "$it:${state.port}") }, + state.hosts.localHostname?.let { IpEntry("Hostname:", "$it:${state.port}") }, + state.hosts.hotspotIp?.let { IpEntry("Hotspot:", "$it:${state.port}") }) + } + + private fun updateIpDropdown(entries: List, placeholder: String? = null) { + ipsAdapter.apply { + clear() + addAll(entries) + notifyDataSetChanged() + } + + binding.actvIps.setText( + entries.firstOrNull()?.value ?: placeholder ?: getString(R.string.waiting_for_network), + false + ) + binding.btnCopyIp.isVisible = entries.isNotEmpty() + } + + private fun observePullRefresh() { + fileServerService ?: return + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + fileServerService!!.pullRefresh.collect { + viewModel.selectedFolderUri.value?.let { viewModel.loadFiles(it) } + } + } + } + } + + private fun observeIpPermissionRequests() { + fileServerService ?: return + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + fileServerService!!.ipPermissionRequests.collect { request -> + if (request.ipAddress in ipPermissionDialogs || request.deferred.isCompleted) return@collect + showIpPermissionDialog(request) + } + } + } + } + + private fun showIpPermissionDialog(request: IpPermissionRequest) { + val ip = request.ipAddress + val dialog = + MaterialAlertDialogBuilder(requireActivity()).setTitle(R.string.permission_request_title) + .setMessage(getString(R.string.permission_request_message, ip)) + .setPositiveButton(R.string.allow) { _, _ -> request.deferred.complete(true) } + .setNegativeButton(R.string.deny) { _, _ -> request.deferred.complete(false) } + .setOnDismissListener { + if (!request.deferred.isCompleted) request.deferred.complete(false) + ipPermissionDialogs.remove(ip) + }.create() + + ipPermissionDialogs[ip] = dialog + dialog.show() + } + + private fun getIpURL(): String? { + val display = binding.actvIps.text?.toString() ?: return showNoIpToast() + val preRaw = display.substringBefore(":").trim() + + if (preRaw.lowercase() == "error") return showNoIpToast() + + val raw = display.substringAfter(": ").trim() + return if (raw.isEmpty()) showNoIpToast() else "http://$raw" + } + + private fun showNoIpToast(): Nothing? { + showToast(R.string.no_ip_available) + return null + } + + private fun startFileServer(folderUri: Uri) { + if (!folderUri.canWrite(requireContext())) { + showToast(R.string.no_write_permission) + startActivity(Intent(requireActivity(), SettingsActivity::class.java)) + return + } + + Intent(requireContext(), FileServerService::class.java).apply { + action = Constants.ACTION_START_SERVICE + putExtra(Constants.EXTRA_FOLDER_URI, folderUri.toString()) + }.also { ContextCompat.startForegroundService(requireContext(), it) } + + if (!isServiceBound) bindService() + } + + private fun bindService() { + Intent(requireContext(), FileServerService::class.java).also { intent -> + requireActivity().bindService(intent, serviceConnection, BIND_AUTO_CREATE) + } + } + + private fun navigateToSettingsWithMessage(resId: Int) { + showToast(resId) + startActivity(Intent(requireActivity(), SettingsActivity::class.java)) + } + + private fun showQrBottomSheet(url: String) { + val tag = QrcodeBottomSheet::class.java.simpleName + if (!parentFragmentManager.isStateSaved && parentFragmentManager.findFragmentByTag(tag) == null) { + QrcodeBottomSheet.newInstance(url).show(parentFragmentManager, tag) + } + } + + private fun showToast(resId: Int, vararg args: Any?) { + Toast.makeText(requireContext(), getString(resId, *args), Toast.LENGTH_SHORT).show() + } + + override fun onResume() { + super.onResume() + fileServerService?.activityResumed() + viewModel.checkSharedFolderUri() + if (!isServiceBound) bindService() + } + + override fun onPause() { + super.onPause() + fileServerService?.activityPaused() + } + + override fun onStop() { + super.onStop() + if (isServiceBound) { + runCatching { + requireActivity().unbindService(serviceConnection) + }.onFailure { + logger.e("Service not registered or already unbound: ${it.message}") + } + isServiceBound = false + } + } + + override fun onDestroyView() { + super.onDestroyView() + ipPermissionDialogs.values.forEach { if (it.isShowing) it.dismiss() } + ipPermissionDialogs.clear() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/FilesFragment.kt b/app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/FilesFragment.kt new file mode 100644 index 0000000..887722d --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/main/fragment/FilesFragment.kt @@ -0,0 +1,222 @@ +package com.matanh.transfer.ui.activity.main.fragment + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.core.view.isVisible +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.matanh.transfer.R +import com.matanh.transfer.databinding.FragmentFilesBinding +import com.matanh.transfer.ui.MainViewModel +import com.matanh.transfer.ui.activity.main.MainActivity +import com.matanh.transfer.util.FileAdapter +import com.matanh.transfer.util.FileItem + +class FilesFragment : Fragment() { + + private var _binding: FragmentFilesBinding? = null + private val binding get() = _binding!! + private val viewModel: MainViewModel by activityViewModels() + private lateinit var adapter: FileAdapter + private var actionMode: ActionMode? = null + + private val actionModeCallback = object : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + actionMode = mode + mode?.menuInflater?.inflate(R.menu.contextual_action_menu, menu) + (activity as? MainActivity)?.hideFAB() + return true + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + menu?.findItem(R.id.action_select_all)?.isVisible = adapter.itemCount > 0 + return true + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + val selectedFiles = adapter.getSelectedFileItems() + if (selectedFiles.isEmpty()) { + showToast(R.string.no_files_selected) + return false + } + + return when (item?.itemId) { + R.id.action_delete_contextual -> { + confirmDeleteMultipleFiles(selectedFiles) + true + } + + R.id.action_share_contextual -> { + shareMultipleFiles(selectedFiles) + true + } + + R.id.action_select_all -> { + adapter.selectAll() + updateActionModeTitle() + true + } + + else -> false + } + } + + override fun onDestroyActionMode(mode: ActionMode?) { + actionMode = null + adapter.clearSelections() + (activity as? MainActivity)?.showFAB() + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ) = FragmentFilesBinding.inflate(inflater, container, false).also { _binding = it }.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupAdapter() + setupRecyclerView() + setupSwipeRefresh() + observeFiles() + } + + private fun setupAdapter() { + adapter = FileAdapter(files = emptyList(), onItemClick = { _, position -> + if (actionMode != null) toggleSelection(position) + else openFile(adapter.getFileItem(position)) + }, onItemLongClick = { _, position -> + if (actionMode == null) { + actionMode = + (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) + } + toggleSelection(position) + true + }) + } + + private fun setupRecyclerView() { + binding.projectList.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = this@FilesFragment.adapter + } + } + + private fun setupSwipeRefresh() { + binding.swiperefreshlayout.setOnRefreshListener { + viewModel.refreshFiles() + } + + // Observe refresh state + viewModel.files.observe(viewLifecycleOwner) { + binding.swiperefreshlayout.isRefreshing = false + } + } + + private fun observeFiles() { + viewModel.files.observe(viewLifecycleOwner) { files -> + adapter.updateFiles(files) + binding.tvNoFilesMessage.isVisible = files.isEmpty() + binding.swiperefreshlayout.isVisible = files.isNotEmpty() + } + } + + private fun toggleSelection(position: Int) { + adapter.toggleSelection(position) + updateActionModeTitle() + } + + private fun updateActionModeTitle() { + val count = adapter.getSelectedItemCount() + if (count == 0) { + actionMode?.finish() + } else { + actionMode?.apply { + title = getString(R.string.selected_items_count, count) + invalidate() + } + } + } + + private fun openFile(fileItem: FileItem?) { + val documentFile = fileItem?.let { + DocumentFile.fromSingleUri(requireContext(), it.uri) + } ?: run { + showToast(R.string.file_not_found) + return + } + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(documentFile.uri, documentFile.type) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + runCatching { + startActivity(Intent.createChooser(intent, getString(R.string.open_with_title))) + }.onFailure { + showToast(R.string.no_app_to_open_file) + } + } + + private fun shareMultipleFiles(files: List) { + val uris = files.mapNotNull { fileItem -> + DocumentFile.fromSingleUri(requireContext(), fileItem.uri)?.takeIf { it.canRead() }?.uri + } + + if (uris.isEmpty()) { + showToast(R.string.no_readable_files_share) + return + } + + val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "*/*" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + runCatching { + startActivity( + Intent.createChooser( + shareIntent, getString(R.string.share_multiple_files_title, uris.size) + ) + ) + }.onFailure { + showToast(R.string.share_file_error, it.message) + } + + actionMode?.finish() + } + + private fun confirmDeleteMultipleFiles(files: List) { + MaterialAlertDialogBuilder(requireContext()).setTitle( + getString( + R.string.confirm_delete_multiple_title, + files.size + ) + ).setMessage(getString(R.string.confirm_delete_multiple_message, files.size)) + .setNegativeButton(R.string.cancel, null).setPositiveButton(R.string.delete) { _, _ -> + viewModel.deleteFiles(files) + actionMode?.finish() + }.show() + } + + private fun showToast(resId: Int, vararg args: Any?) { + Toast.makeText(requireContext(), getString(resId, *args), Toast.LENGTH_SHORT).show() + } + + override fun onDestroyView() { + super.onDestroyView() + actionMode?.finish() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/settings/LookAndFeel.kt b/app/src/main/java/com/matanh/transfer/ui/activity/settings/LookAndFeel.kt new file mode 100644 index 0000000..bf4ae81 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/settings/LookAndFeel.kt @@ -0,0 +1,117 @@ +package com.matanh.transfer.ui.activity.settings + +import android.os.Build +import android.view.View +import android.widget.RadioButton +import androidx.appcompat.app.AppCompatDelegate +import com.matanh.transfer.databinding.ActivityLookAndFeelBinding +import com.matanh.transfer.ui.common.ActivityReloader +import com.matanh.transfer.ui.common.BaseActivity +import com.matanh.transfer.ui.common.booleanState +import com.matanh.transfer.ui.common.intState +import com.matanh.transfer.util.AMOLED_THEME +import com.matanh.transfer.util.DYNAMIC_THEME +import com.matanh.transfer.util.HAPTICS_VIBRATION +import com.matanh.transfer.util.PreferenceUtil.updateBoolean +import com.matanh.transfer.util.PreferenceUtil.updateInt +import com.matanh.transfer.util.THEME_MODE +import com.matanh.transfer.util.ThemeUtil + +class LookAndFeelActivity : + BaseActivity(ActivityLookAndFeelBinding::inflate) { + + override fun init() { + + setupThemeOptions() + setupAmoledSwitch() + setupDynamicColorsSwitch() + setupHapticAndVibration() + } + + override fun initLogic() { + setupBackPress() + } + + private fun setupBackPress() { + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { + it.hapticClick() + onBackPressedDispatcher.onBackPressed() + } + } + + private fun setupThemeOptions() { + setRadioButtonState(binding.system, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + setRadioButtonState(binding.on, AppCompatDelegate.MODE_NIGHT_YES) + setRadioButtonState(binding.off, AppCompatDelegate.MODE_NIGHT_NO) + + // Dark versions click -> trigger radio + binding.darkSystem.setOnClickListener { binding.system.performClick() } + binding.darkOn.setOnClickListener { binding.on.performClick() } + binding.darkOff.setOnClickListener { binding.off.performClick() } + } + + private fun setRadioButtonState(button: RadioButton, mode: Int) { + button.isChecked = THEME_MODE.intState == mode + button.setOnClickListener { v -> + if (THEME_MODE.intState != mode) { + v.hapticClick() + handleRadioButtonSelection(button, mode) + } + } + } + + private fun handleRadioButtonSelection(button: RadioButton, mode: Int) { + clearRadioButtons() + button.isChecked = true + THEME_MODE.updateInt(mode) + AppCompatDelegate.setDefaultNightMode(mode) + } + + private fun clearRadioButtons() { + binding.system.isChecked = false + binding.on.isChecked = false + binding.off.isChecked = false + } + + private fun setupAmoledSwitch() { + binding.switchHighContrastDarkTheme.isChecked = AMOLED_THEME.booleanState + binding.switchHighContrastDarkTheme.setOnCheckedChangeListener { view, isChecked -> + view.hapticClick() + AMOLED_THEME.updateBoolean(isChecked) + if (ThemeUtil.isNightMode(this)) { + ActivityReloader.recreateAll() + } + } + binding.highContrastDarkTheme.setOnClickListener { + binding.switchHighContrastDarkTheme.performClick() + } + } + + private fun setupDynamicColorsSwitch() { + binding.dynamicColors.visibility = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) View.VISIBLE else View.GONE + + binding.switchDynamicColors.isChecked = DYNAMIC_THEME.booleanState + binding.switchDynamicColors.setOnCheckedChangeListener { view, isChecked -> + view.hapticClick() + DYNAMIC_THEME.updateBoolean(isChecked) + ActivityReloader.recreateAll() + } + binding.dynamicColors.setOnClickListener { + binding.switchDynamicColors.performClick() + } + } + + private fun setupHapticAndVibration() { + binding.switchHapticAndVibration.isChecked = HAPTICS_VIBRATION.booleanState + binding.switchHapticAndVibration.setOnCheckedChangeListener { view, isChecked -> + view.hapticClick() + HAPTICS_VIBRATION.updateBoolean(isChecked) + } + binding.hapticAndVibration.setOnClickListener { + binding.switchHapticAndVibration.performClick() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/settings/SettingsActivity.kt b/app/src/main/java/com/matanh/transfer/ui/activity/settings/SettingsActivity.kt new file mode 100644 index 0000000..8d740d7 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/settings/SettingsActivity.kt @@ -0,0 +1,274 @@ +package com.matanh.transfer.ui.activity.settings + +import android.content.Context +import android.net.Uri +import android.text.InputType +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.matanh.transfer.R +import com.matanh.transfer.databinding.ActivitySettingsBinding +import com.matanh.transfer.ui.common.BaseActivity +import com.matanh.transfer.ui.views.SettingsCategoryView +import com.matanh.transfer.ui.views.SettingsItemView +import com.matanh.transfer.ui.views.SettingsSwitchItemView +import com.matanh.transfer.util.Constants +import com.matanh.transfer.util.FileUtils.persistFolderUri + +class SettingsActivity : BaseActivity(ActivitySettingsBinding::inflate) { + + private lateinit var sharedFolderPref: SettingsItemView + + private val selectFolderLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> + if (uri != null) { + persistFolderUri(uri) + putPrefString(Constants.EXTRA_FOLDER_URI, uri.toString()) + sharedFolderPref.setDescription(getFolderSummary()) + toast(getString(R.string.folder_setup_complete)) + } + } + + fun launchFolderSelection() { + selectFolderLauncher.launch(null) + } + + override fun init() { + setupContent(binding.content) + } + + override fun initLogic() { + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { + it.hapticClick() + onBackPressedDispatcher.onBackPressed() + } + } + + private fun setupContent(content: ViewGroup) { + + val categories = listOf( + SettingsCategoryView(this).apply { + setTitle("General") + setItems( + buildList { + add( + createPreference( + R.drawable.palette, "Look & feel", "Dynamic colors, Dark theme" + ) { + it.hapticClick() + openActivity() + }) + } + ) + }, + + SettingsCategoryView(this).apply { + setTitle("Security") + + val ipPermissionKey = getString(R.string.pref_key_ip_permission_enabled) + val serverPasswordKey = getString(R.string.pref_key_server_password) + + val isIpPermissionEnabled = getPrefBoolean(ipPermissionKey, true) + + setItems( + buildList { + + add( + createSwitchPreference( + icon = R.drawable.app_badging, + title = "Display Permission Dialog", + desc = "Ask for permission for new IP's. Valid for 1 hour", + isChecked = isIpPermissionEnabled + ) { enabled -> + binding.content.hapticClick() + putPrefBoolean(ipPermissionKey, enabled) + } + ) + + add( + SettingsItemView(this@SettingsActivity).apply { + setContent( + R.drawable.password, + title, + getPasswordSummary(serverPasswordKey) + ) + setOnClickListener { + it.hapticClick() + this@SettingsActivity.showTextInputDialog( + title = "Enter a password", hintText = "Password" + ) { password -> + putPrefString(serverPasswordKey, password) + setDescription(getPasswordSummary(serverPasswordKey)) + } + } + } + ) + + add( + SettingsItemView(this@SettingsActivity).apply { + setContent( + R.drawable.folder, "Shared Folder", getFolderSummary() + ) + + sharedFolderPref = this + + setOnClickListener { + it.hapticClick() + launchFolderSelection() + } + } + ) + } + ) + } + ) + + categories.forEach(content::addView) + } + + private fun Context.getFolderSummary(): String { + val uriString = getPrefString(Constants.EXTRA_FOLDER_URI, "") + if (uriString.isEmpty()) return "No folder selected" + + val uri = uriString.toUri() + val docFile = DocumentFile.fromTreeUri(this, uri) + return docFile?.name ?: uri.path.orEmpty() + } + + fun Context.showTextInputDialog( + title: String, + hintText: String, + onResult: (String) -> Unit + ) { + val density = resources.displayMetrics.density + fun dp(value: Int) = (value * density).toInt() + + val password = getPrefString(getString(R.string.pref_key_server_password), "") + + val inputLayout = TextInputLayout(this).apply { + + // Material style + boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE + setBoxCornerRadii( dp(8).toFloat(), + dp(8).toFloat(), + dp(8).toFloat(), + dp(8).toFloat()) + + // Spacing + setPadding(dp(16), dp(8), dp(16), dp(8)) + + layoutParams = ViewGroup.MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(dp(20), dp(12), dp(20), dp(4)) + } + + hint = hintText + } + + val editText = TextInputEditText(this).apply { + + setPadding(dp(12), dp(12), dp(12), dp(12)) + + textSize = 16f + isSingleLine = true + + setText(password) + + // Enable "Done" action + imeOptions = EditorInfo.IME_ACTION_DONE + inputType = InputType.TYPE_CLASS_TEXT + + setTextColor(getThemeColor(android.R.attr.textColorPrimary)) + setHintTextColor(getThemeColor(android.R.attr.textColorHint)) + + } + + inputLayout.addView(editText) + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(title) + .setView(inputLayout) + .setPositiveButton("OK", null) + .setNegativeButton("Cancel", null) + .create() + + dialog.setOnShowListener { + + val okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + + // OK button logic + val submit = { + val text = editText.text?.toString()?.trim().orEmpty() + onResult(text) + dialog.dismiss() + } + + okButton.setOnClickListener { + submit() + } + + // IME Done → OK + editText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + true + } else { + false + } + } + + // Autofocus + show keyboard + editText.requestFocus() + editText.post { + val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + } + } + + dialog.show() + } + + + private fun Context.getPasswordSummary(key: String): String { + return if (getPrefString(key, "").isNotEmpty()) { + getString(R.string.pref_summary_password_protect_on) + } else { + getString(R.string.pref_summary_password_protect_off) + } + } + + + private fun createPreference( + @DrawableRes icon: Int, + title: CharSequence, + desc: CharSequence, + listener: View.OnClickListener + ): SettingsItemView = SettingsItemView(this).apply { + setContent(icon, title, desc) + setOnClickListener(listener) + } + + private fun createSwitchPreference( + @DrawableRes icon: Int, + title: CharSequence, + desc: CharSequence, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit + ): SettingsSwitchItemView = SettingsSwitchItemView(this).apply { + setContent(icon, title, desc, isChecked) + setOnCheckedChangeListener(onCheckedChange) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/activity/startup/StartUpActivity.kt b/app/src/main/java/com/matanh/transfer/ui/activity/startup/StartUpActivity.kt new file mode 100644 index 0000000..785599d --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/activity/startup/StartUpActivity.kt @@ -0,0 +1,86 @@ +package com.matanh.transfer.ui.activity.startup + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.matanh.transfer.R +import com.matanh.transfer.databinding.ActivityStartUpBinding +import com.matanh.transfer.ui.activity.main.MainActivity +import com.matanh.transfer.ui.common.BaseActivity +import com.matanh.transfer.util.Constants +import com.matanh.transfer.util.FileUtils.clearPersistedFolderUri +import com.matanh.transfer.util.FileUtils.hasPersistedReadWritePermission +import com.matanh.transfer.util.FileUtils.persistFolderUri + +class StartUpActivity : BaseActivity(ActivityStartUpBinding::inflate) { + + private val selectFolderLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + persistFolderUri(uri) + putPrefString(Constants.EXTRA_FOLDER_URI, uri.toString()) + toast(getString(R.string.folder_setup_complete)) + openActivity(clearTaskAndFinish = true) + } else { + toast(getString(R.string.folder_selection_cancelled)) + } + } + + private val requestNotificationPermissionLauncher = + registerForActivityResult(RequestPermission()) { isGranted -> + if (!isGranted) toast(getString(R.string.notification_permission_denied)) + } + + override fun init() { + requestNotificationPermissionIfNeeded() + + if (checkExistingFolderPermission()) { + openActivity(clearTaskAndFinish = true) + } + } + + override fun initLogic() { + binding.btnNext.setOnClickListener { + it.hapticClick() + selectFolderLauncher.launch(null) + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + private fun checkExistingFolderPermission(): Boolean { + val persistedUriString = getPrefString(Constants.EXTRA_FOLDER_URI, "null") + + if (persistedUriString.isEmpty()) return false + + val persistedUri = persistedUriString.toUri() + + if (!hasPersistedReadWritePermission(persistedUri)) { + clearPersistedFolderUri() + return false + } + + val docFile = DocumentFile.fromTreeUri(this, persistedUri) + + return if (docFile?.canRead() == true) { + true + } else { + clearPersistedFolderUri() + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/adapter/MainPagerAdapter.kt b/app/src/main/java/com/matanh/transfer/ui/adapter/MainPagerAdapter.kt new file mode 100644 index 0000000..c601e51 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/adapter/MainPagerAdapter.kt @@ -0,0 +1,23 @@ +package com.matanh.transfer.ui.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.matanh.transfer.ui.activity.main.fragment.ConfigFragment +import com.matanh.transfer.ui.activity.main.fragment.FilesFragment + +class MainPagerAdapter( + activity: FragmentActivity +) : FragmentStateAdapter(activity) { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment { + + return when (position) { + 0 -> ConfigFragment() + 1 -> FilesFragment() + else -> throw IllegalStateException("Invalid position: $position") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/adapter/TeamAdapter.kt b/app/src/main/java/com/matanh/transfer/ui/adapter/TeamAdapter.kt new file mode 100644 index 0000000..bd5d049 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/adapter/TeamAdapter.kt @@ -0,0 +1,51 @@ +package com.matanh.transfer.ui.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.matanh.transfer.databinding.ListItemTeamMemberBinding +import com.matanh.transfer.ui.items.TeamMember +import com.matanh.transfer.util.HapticUtils + +class TeamAdapter( + private val onClick: (TeamMember) -> Unit +) : ListAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + ListItemTeamMemberBinding.inflate(LayoutInflater.from(parent.context), parent, false), + onClick + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position), position == itemCount - 1) + } + + class ViewHolder( + private val binding: ListItemTeamMemberBinding, private val onClick: (TeamMember) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(member: TeamMember, isLast: Boolean) = with(binding) { + textViewTeamMemberName.text = member.name + + Glide.with(imageViewTeamMember).load(member.imageUrl).circleCrop() + .into(imageViewTeamMember) + + divider.isVisible = !isLast + + root.setOnClickListener { + HapticUtils.weakVibrate(it) + onClick(member) + } + } + } + + private companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: TeamMember, new: TeamMember) = old.social == new.social + + override fun areContentsTheSame(old: TeamMember, new: TeamMember) = old == new + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/common/ActivityReloader.kt b/app/src/main/java/com/matanh/transfer/ui/common/ActivityReloader.kt new file mode 100644 index 0000000..0b65094 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/common/ActivityReloader.kt @@ -0,0 +1,22 @@ +package com.matanh.transfer.ui.common + +import androidx.appcompat.app.AppCompatActivity + +object ActivityReloader { + + private val activities = mutableSetOf() + + fun register(activity: AppCompatActivity) { + activities.add(activity) + } + + fun unregister(activity: AppCompatActivity) { + activities.remove(activity) + } + + fun recreateAll() { + activities.forEach { activity -> + activity.recreate() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/common/BaseActivity.kt b/app/src/main/java/com/matanh/transfer/ui/common/BaseActivity.kt new file mode 100644 index 0000000..64fe10c --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/common/BaseActivity.kt @@ -0,0 +1,145 @@ +package com.matanh.transfer.ui.common + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.viewbinding.ViewBinding +import com.google.android.material.color.MaterialColors +import com.matanh.transfer.util.Constants +import com.matanh.transfer.util.HapticUtils +import com.matanh.transfer.util.ThemeUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +abstract class BaseActivity( + private val bindingInflater: (LayoutInflater) -> VB +) : AppCompatActivity() { + + protected lateinit var binding: VB + private set + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeUtil.updateTheme(this) + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + + binding = bindingInflater(layoutInflater) + setContentView(binding.root) + ActivityReloader.register(this) + + init() + initLogic() + } + + + override fun onDestroy() { + ActivityReloader.unregister(this) + super.onDestroy() + } + + protected abstract fun init() + protected abstract fun initLogic() + + fun Context.isNightMode(): Boolean { + val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return mode == Configuration.UI_MODE_NIGHT_YES + } + + fun View.show() { + isVisible = true + } + + fun View.hide() { + isGone = true + } + + fun restartApp() { + finishAffinity() + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + launchIntent?.let { + it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(it) + } + } + + fun delayTask(delayMillis: Long = 200, task: () -> Unit) { + lifecycleScope.launch { + delay(delayMillis) + task() + } + } + + fun Context.toast(message: CharSequence, long: Boolean = false) { + Toast.makeText( + this, message, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + ).show() + } + + fun View.hapticClick() { + HapticUtils.weakVibrate(this) + } + + inline fun openActivity( + clearTaskAndFinish: Boolean = false + ) { + val intent = Intent(this, T::class.java) + + if (clearTaskAndFinish) { + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + finish() + } else { + startActivity(intent) + } + } + + fun Context.putPrefString(key: String, value: String) { + getSharedPreferences(Constants.SHARED_PREFS_NAME, MODE_PRIVATE).edit { + putString(key, value) + } + } + + fun Context.putPrefBoolean(key: String, value: Boolean) { + getSharedPreferences(Constants.SHARED_PREFS_NAME, MODE_PRIVATE).edit { + putBoolean(key, value) + } + } + + fun Context.getPrefString( + key: String, default: String + ): String { + return getSharedPreferences(Constants.SHARED_PREFS_NAME, MODE_PRIVATE).getString(key, default) + ?: default + } + + fun Context.getPrefBoolean( + key: String, default: Boolean + ): Boolean { + return getSharedPreferences(Constants.SHARED_PREFS_NAME, MODE_PRIVATE).getBoolean(key, default) + } + + fun Context.getThemeColor(attr: Int, fallback: Int = Color.TRANSPARENT): Int { + return MaterialColors.getColor(this, attr, fallback) + } + + fun uiThread(action: () -> Unit) { + if (!isFinishing) { + lifecycleScope.launch(Dispatchers.Main) { action() } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/common/Ext.kt b/app/src/main/java/com/matanh/transfer/ui/common/Ext.kt new file mode 100644 index 0000000..0c052a2 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/common/Ext.kt @@ -0,0 +1,14 @@ +package com.matanh.transfer.ui.common + +import com.matanh.transfer.util.PreferenceUtil.getBoolean +import com.matanh.transfer.util.PreferenceUtil.getInt +import com.matanh.transfer.util.PreferenceUtil.getString + +inline val String.booleanState + get() = this.getBoolean() + +inline val String.stringState + get() = this.getString() + +inline val String.intState + get() = this.getInt() \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/common/HapticFeedback.kt b/app/src/main/java/com/matanh/transfer/ui/common/HapticFeedback.kt new file mode 100644 index 0000000..dcfdf1d --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/common/HapticFeedback.kt @@ -0,0 +1,12 @@ +package com.matanh.transfer.ui.common + +import android.view.HapticFeedbackConstants +import android.view.View + +object HapticFeedback { + fun View.slightHapticFeedback() = + this.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + + fun View.longPressHapticFeedback() = + this.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/common/UiExtensions.kt b/app/src/main/java/com/matanh/transfer/ui/common/UiExtensions.kt new file mode 100644 index 0000000..05fd652 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/common/UiExtensions.kt @@ -0,0 +1,21 @@ +package com.matanh.transfer.ui.common + +import android.content.Context +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt + +@ColorInt +fun Context.resolveColorAttr(@AttrRes attr: Int): Int { + val tv = TypedValue() + theme.resolveAttribute(attr, tv, true) + return tv.data +} + +fun View.setBottomMarginDp(dp: Int) { + val params = layoutParams as? ViewGroup.MarginLayoutParams ?: return + params.bottomMargin = (dp * resources.displayMetrics.density).toInt() + layoutParams = params +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/components/InsetsHelper.kt b/app/src/main/java/com/matanh/transfer/ui/components/InsetsHelper.kt new file mode 100644 index 0000000..6fa3797 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/components/InsetsHelper.kt @@ -0,0 +1,64 @@ +package com.matanh.transfer.ui.components + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams + +object InsetsHelper { + + /** + * Apply system bar insets as margin + */ + fun applyMargin( + view: View, + left: Boolean = false, + top: Boolean = false, + right: Boolean = false, + bottom: Boolean = false + ) { + + val initialMargins = getInitialMargins(view) + + ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> + + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() + ) + + v.updateLayoutParams { + + if (left) leftMargin = initialMargins.left + bars.left + if (top) topMargin = initialMargins.top + bars.top + if (right) rightMargin = initialMargins.right + bars.right + if (bottom) bottomMargin = initialMargins.bottom + bars.bottom + } + + insets + } + + ViewCompat.requestApplyInsets(view) + } + + // ------------------------- + // Internal + // ------------------------- + + private fun getInitialMargins(view: View): Margins { + + val lp = view.layoutParams as? ViewGroup.MarginLayoutParams + + ?: throw IllegalArgumentException( + "View must use MarginLayoutParams" + ) + + return Margins( + lp.leftMargin, lp.topMargin, lp.rightMargin, lp.bottomMargin + ) + } + + private data class Margins( + val left: Int, val top: Int, val right: Int, val bottom: Int + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/items/TeamMember.kt b/app/src/main/java/com/matanh/transfer/ui/items/TeamMember.kt new file mode 100644 index 0000000..4802025 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/items/TeamMember.kt @@ -0,0 +1,7 @@ +package com.matanh.transfer.ui.items + +data class TeamMember( + val imageUrl: String, + val name: String, + val social: String +) \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryItem.kt b/app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryItem.kt new file mode 100644 index 0000000..d989eee --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryItem.kt @@ -0,0 +1,3 @@ +package com.matanh.transfer.ui.views + +interface SettingsCategoryItem \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryView.kt b/app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryView.kt new file mode 100644 index 0000000..673e1e2 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/views/SettingsCategoryView.kt @@ -0,0 +1,36 @@ +package com.matanh.transfer.ui.views + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import com.matanh.transfer.databinding.SettingsCategoryItemBinding + +class SettingsCategoryView( + context: Context +) : FrameLayout(context) { + + private val binding = SettingsCategoryItemBinding.inflate( + LayoutInflater.from(context), this, true + ) + + fun setTitle(title: CharSequence?) = with(binding.title) { + visibility = if (title == null) GONE else VISIBLE + text = title + } + + fun setItems(items: List) { + binding.container.removeAllViews() + + items.forEachIndexed { index, item -> + val view = item as View + binding.container.addView(view) + + if (item is SettingsItemView) { + item.showDivider(index != items.lastIndex) + } else if (item is SettingsSwitchItemView) { + item.showDivider(index != items.lastIndex) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/views/SettingsItemView.kt b/app/src/main/java/com/matanh/transfer/ui/views/SettingsItemView.kt new file mode 100644 index 0000000..45200b0 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/views/SettingsItemView.kt @@ -0,0 +1,42 @@ +package com.matanh.transfer.ui.views + +import android.content.Context +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.annotation.DrawableRes +import com.matanh.transfer.databinding.SettingsItemBinding + +class SettingsItemView( + context: Context +) : FrameLayout(context), SettingsCategoryItem { + + private val binding = SettingsItemBinding.inflate( + LayoutInflater.from(context), this, true + ) + + init { + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT + ) + } + + fun setContent( + @DrawableRes iconRes: Int, titleText: CharSequence, descText: CharSequence + ) = with(binding) { + icon.setImageResource(iconRes) + title.text = titleText + description.text = descText + } + + fun setDescription(desc: CharSequence) { + binding.description.text = desc + } + + fun showDivider(show: Boolean) { + binding.divider.visibility = if (show) VISIBLE else GONE + } + + override fun setOnClickListener(listener: OnClickListener?) { + binding.container.setOnClickListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/views/SettingsSwitchItemView.kt b/app/src/main/java/com/matanh/transfer/ui/views/SettingsSwitchItemView.kt new file mode 100644 index 0000000..9edce45 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/ui/views/SettingsSwitchItemView.kt @@ -0,0 +1,61 @@ +package com.matanh.transfer.ui.views + +import android.content.Context +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.annotation.DrawableRes +import com.matanh.transfer.databinding.SettingsSwitchItemBinding +import com.matanh.transfer.util.HapticUtils + +class SettingsSwitchItemView( + context: Context +) : FrameLayout(context), SettingsCategoryItem { + + private val binding = SettingsSwitchItemBinding.inflate( + LayoutInflater.from(context), this, true + ) + + init { + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT + ) + setupClickBehavior() + } + + private fun setupClickBehavior() { + binding.container.setOnClickListener { + HapticUtils.weakVibrate(it) + binding.switchView.toggle() + } + + binding.switchView.isClickable = true + binding.switchView.isFocusable = true + + binding.switchView.setOnClickListener { + // let MaterialSwitch handle everything + } + } + + fun setContent( + @DrawableRes iconRes: Int, + titleText: CharSequence, + descText: CharSequence, + isChecked: Boolean + ) = with(binding) { + icon.setImageResource(iconRes) + title.text = titleText + description.text = descText + switchView.isChecked = isChecked + } + + fun showDivider(show: Boolean) { + binding.divider.visibility = if (show) VISIBLE else GONE + } + + fun setOnCheckedChangeListener(listener: (Boolean) -> Unit) { + binding.switchView.setOnCheckedChangeListener { _, isChecked -> + listener(isChecked) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/Constants.kt b/app/src/main/java/com/matanh/transfer/util/Constants.kt index bda05d6..510438a 100644 --- a/app/src/main/java/com/matanh/transfer/util/Constants.kt +++ b/app/src/main/java/com/matanh/transfer/util/Constants.kt @@ -8,6 +8,11 @@ object Constants { const val ACTION_STOP_SERVICE = "ACTION_STOP_SERVICE" const val SHARED_PREFS_NAME = "TransferPrefs" const val IP_PERMISSION_VALIDITY_MS = 60 * 60 * 1000L // 1 hour - const val EXTRA_FOLDER_URI = "FOLDER_URI" +} + +object Url { + const val REPO_URL = "https://github.com/matan-h/transfer" + const val REPO_CHANGE_LOG_URL = "https://github.com/matan-h/Transfer/releases" + const val DEV_COFFEE_URL = "https://coff.ee/matanh" } \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/ErrorReport.kt b/app/src/main/java/com/matanh/transfer/util/ErrorReport.kt index e607384..c8f1200 100644 --- a/app/src/main/java/com/matanh/transfer/util/ErrorReport.kt +++ b/app/src/main/java/com/matanh/transfer/util/ErrorReport.kt @@ -7,37 +7,41 @@ import androidx.core.net.toUri import com.matanh.transfer.BuildConfig class ErrorReport { - val baseUri = "https://github.com/matan-h/Transfer/issues/new?template=bug_report.yml&".toUri(); + val baseUri = "https://github.com/matan-h/Transfer/issues/new?template=bug_report.yml&".toUri() @Suppress("SameParameterValue") - private fun gen_url(deviceModel: String, androidVersion: String, appVersion: String, additionalText: String): String { + private fun gen_url( + deviceModel: String, + androidVersion: String, + appVersion: String, + additionalText: String + ): String { // val url = base_url.format(additionalText, device_model, android_version, app_version); - val url = baseUri - .buildUpon() - .appendQueryParameter("device",deviceModel) + val url = baseUri.buildUpon().appendQueryParameter("device", deviceModel) .appendQueryParameter("android_version", androidVersion) .appendQueryParameter("app_version", appVersion) - .appendQueryParameter("additional_context", additionalText) - .build() - .toString() - return url; + .appendQueryParameter("additional_context", additionalText).build().toString() + return url } - fun openReport(ctx: Context){ + + fun openReport(ctx: Context) { val logs = TransferApp.memoryTree.getLog() val additionalText = """ Installer Package: ${getInstallerPackage(ctx)} App logs: ``` -${ logs.joinToString("\n")} +${logs.joinToString("\n")} ``` """.trimMargin() - val url = gen_url(Build.MODEL, Build.VERSION.RELEASE, BuildConfig.VERSION_NAME, additionalText) + val url = + gen_url(Build.MODEL, Build.VERSION.RELEASE, BuildConfig.VERSION_NAME, additionalText) val intent = Intent(Intent.ACTION_VIEW, url.toUri()) ctx.startActivity(intent) } + private fun getInstallerPackage(ctx: Context): String { val pm = ctx.packageManager val packageName = ctx.packageName @@ -45,5 +49,4 @@ ${ logs.joinToString("\n")} } - } \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/FileAdapter.kt b/app/src/main/java/com/matanh/transfer/util/FileAdapter.kt index d9f3596..34c5755 100644 --- a/app/src/main/java/com/matanh/transfer/util/FileAdapter.kt +++ b/app/src/main/java/com/matanh/transfer/util/FileAdapter.kt @@ -5,107 +5,91 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel import com.matanh.transfer.R +import com.matanh.transfer.util.FileUtils.toReadableFileSize class FileAdapter( private var files: List, - private val onItemClick: (FileItem, Int) -> Unit, // For regular clicks - private val onItemLongClick: (FileItem, Int) -> Boolean // For long clicks to start action mode + private val onItemClick: (FileItem, Int) -> Unit, + private val onItemLongClick: (FileItem, Int) -> Boolean ) : RecyclerView.Adapter() { - private val selectedItems = mutableSetOf() // Stores positions of selected items + private val selectedItems = mutableSetOf() inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val tvName: TextView = view.findViewById(R.id.tvFileName) val tvSize: TextView = view.findViewById(R.id.tvFileSize) val ivSelectionCheck: ImageView = view.findViewById(R.id.ivSelectionCheck) - val itemLayout: ConstraintLayout = - view as ConstraintLayout // Assuming root is ConstraintLayout + val linearBody: MaterialCardView = view.findViewById(R.id.linearBody) fun bind(file: FileItem, position: Int, isSelected: Boolean) { tvName.text = file.name - tvSize.text = FileUtils.formatFileSize(file.size) - - - if (isSelected) { - itemLayout.setBackgroundColor( - ContextCompat.getColor( - itemView.context, - R.color.file_item_selected_background - ) - ) - ivSelectionCheck.visibility = View.VISIBLE - } else { - itemLayout.setBackgroundColor( - ContextCompat.getColor( - itemView.context, - R.color.default_file_item_background - ) - ) - - ivSelectionCheck.visibility = View.GONE - } - - itemView.setOnClickListener { - onItemClick(file, position) - } - itemView.setOnLongClickListener { - onItemLongClick(file, position) - } + tvSize.text = file.size.toReadableFileSize() + ivSelectionCheck.isVisible = isSelected + + linearBody.shapeAppearanceModel = createCornerShape(position) + + itemView.setOnClickListener { onItemClick(file, position) } + itemView.setOnLongClickListener { onItemLongClick(file, position) } } - } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context).inflate(R.layout.item_file, parent, false) - return ViewHolder(view) + private fun createCornerShape(position: Int): ShapeAppearanceModel { + val dp = itemView.resources.displayMetrics.density + val corners = when { + itemCount == 1 -> listOf(18f, 18f, 18f, 18f) + position == 0 -> listOf(18f, 18f, 6f, 6f) + position == itemCount - 1 -> listOf(6f, 6f, 18f, 18f) + else -> listOf(6f, 6f, 6f, 6f) + }.map { it * dp } + + return ShapeAppearanceModel.builder().setTopLeftCorner(CornerFamily.ROUNDED, corners[0]) + .setTopRightCorner(CornerFamily.ROUNDED, corners[1]) + .setBottomLeftCorner(CornerFamily.ROUNDED, corners[2]) + .setBottomRightCorner(CornerFamily.ROUNDED, corners[3]).build() + } } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.item_file, parent, false) + ) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(files[position], position, selectedItems.contains(position)) + holder.bind(files[position], position, position in selectedItems) } - override fun getItemCount(): Int = files.size + override fun getItemCount() = files.size fun updateFiles(newFiles: List) { files = newFiles - selectedItems.clear() // Clear selection on new data + selectedItems.clear() notifyDataSetChanged() } fun toggleSelection(position: Int) { - if (selectedItems.contains(position)) { - selectedItems.remove(position) - } else { - selectedItems.add(position) - } + if (position in selectedItems) selectedItems.remove(position) + else selectedItems.add(position) notifyItemChanged(position) } - fun getSelectedFileItems(): List { - return selectedItems.map { files[it] } - } + fun getSelectedFileItems() = selectedItems.map { files[it] } - fun getSelectedItemCount(): Int { - return selectedItems.size - } + fun getSelectedItemCount() = selectedItems.size fun clearSelections() { selectedItems.clear() - notifyDataSetChanged() // To redraw all items to their non-selected state - } - fun getFileItem(position: Int): FileItem? { - return files.getOrNull(position) + notifyDataSetChanged() } + fun getFileItem(position: Int) = files.getOrNull(position) + fun selectAll() { - if (selectedItems.size == files.size) { // If all are selected, deselect all - selectedItems.clear() - } else { // Otherwise, select all - files.forEachIndexed { index, _ -> selectedItems.add(index) } - } + if (selectedItems.size == files.size) selectedItems.clear() + else selectedItems.addAll(files.indices) notifyDataSetChanged() } } \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/FileUtils.kt b/app/src/main/java/com/matanh/transfer/util/FileUtils.kt index 430423c..decc884 100644 --- a/app/src/main/java/com/matanh/transfer/util/FileUtils.kt +++ b/app/src/main/java/com/matanh/transfer/util/FileUtils.kt @@ -1,6 +1,6 @@ package com.matanh.transfer.util -import android.annotation.SuppressLint +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.net.Uri @@ -9,61 +9,45 @@ import androidx.core.content.edit import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlin.math.ln +import kotlin.math.pow object FileUtils { - fun getFileName(context: Context, uri: Uri): String? { - var name: String? = null - if (uri.scheme == "content") { - val cursor = context.contentResolver.query(uri, null, null, null, null) - cursor?.use { - if (it.moveToFirst()) { - val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1) { - name = it.getString(nameIndex) - } - } - } - } - if (name == null) { - name = uri.path - val cut = name?.lastIndexOf('/') - if (cut != -1 && cut != null) { - name = name?.substring(cut + 1) + fun Context.getFileName(uri: Uri): String = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null + )?.use { cursor -> + cursor.takeIf { it.moveToFirst() }?.getString(0) } - } - return name ?: "unknown_file" - } - suspend fun generateUniqueFileName( - docDir: DocumentFile, - name: String, - extension: String, - startFromOne: Boolean = false + else -> uri.lastPathSegment + } ?: "unknown_file" + + suspend fun DocumentFile.generateUniqueFileName( + baseName: String, extension: String, startFromOne: Boolean = false ): String = withContext(Dispatchers.IO) { - // If we’re not starting from 1, try the plain name first: - if (!startFromOne) { - val plainName = "$name.$extension" - if (docDir.findFile(plainName) == null) { - return@withContext plainName - } + + fun candidate(index: Int) = "$baseName${if (index == 0) "" else "_$index"}.$extension" + + if (!startFromOne && findFile(candidate(0)) == null) { + return@withContext candidate(0) } - var count = if (startFromOne) 1 else 2 - var candidate: String - do { - candidate = "${name}_$count.$extension" - count++ - } while (docDir.findFile(candidate) != null) + var index = if (startFromOne) 1 else 2 + while (findFile(candidate(index)) != null) { + index++ + } - return@withContext candidate + candidate(index) } suspend fun copyUriToAppDir( - context: Context, - sourceUri: Uri, - destinationDirUri: Uri, - filename: String - ): DocumentFile? = withContext(Dispatchers.IO){ + context: Context, sourceUri: Uri, destinationDirUri: Uri, filename: String + ): DocumentFile? = withContext(Dispatchers.IO) { val resolver = context.contentResolver val docDir = DocumentFile.fromTreeUri(context, destinationDirUri) ?: return@withContext null @@ -72,7 +56,7 @@ object FileUtils { // Check if file exists, if so, create a unique name - var finalFileName = generateUniqueFileName(docDir, nameWithoutExt, ext) + val finalFileName = docDir.generateUniqueFileName(nameWithoutExt, ext) val mimeType = resolver.getType(sourceUri) ?: "application/octet-stream" @@ -93,14 +77,10 @@ object FileUtils { } suspend fun createTextFileInDir( - context: Context, - dirUri: Uri, - name: String, - ext: String, - content: String + context: Context, dirUri: Uri, name: String, ext: String, content: String ): DocumentFile? = withContext(Dispatchers.IO) { val docDir = DocumentFile.fromTreeUri(context, dirUri) ?: return@withContext null - val fileName = generateUniqueFileName(docDir, name, ext, true) + val fileName = docDir.generateUniqueFileName(name, ext, true) val targetFile = docDir.createFile("text/plain", fileName) ?: return@withContext null try { @@ -115,43 +95,52 @@ object FileUtils { return@withContext null } - fun persistUriPermission(context: Context, uri: Uri) { - val contentResolver = context.contentResolver - val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - contentResolver.takePersistableUriPermission(uri, takeFlags) + fun Context.persistFolderUri(uri: Uri) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - // Store the URI string for later use - val prefs = context.getSharedPreferences(Constants.SHARED_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(Constants.EXTRA_FOLDER_URI, uri.toString()) } - } + runCatching { + contentResolver.takePersistableUriPermission(uri, flags) + }.onFailure { + // Invalid or non-persistable URI — ignore or log + return + } - fun isUriPermissionPersisted(context: Context, uri: Uri): Boolean { - val persistedUriPermissions = context.contentResolver.persistedUriPermissions - return persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission } + getSharedPreferences(Constants.SHARED_PREFS_NAME, Context.MODE_PRIVATE).edit { + putString(Constants.EXTRA_FOLDER_URI, uri.toString()) + } } - fun clearPersistedUri(context: Context) { - val prefs = context.getSharedPreferences(Constants.SHARED_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { remove(Constants.EXTRA_FOLDER_URI) } + fun Context.hasPersistedReadWritePermission(uri: Uri): Boolean = + contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + + fun Context.clearPersistedFolderUri() { + contentResolver.persistedUriPermissions.firstOrNull { it.isWritePermission }?.let { + contentResolver.releasePersistableUriPermission( + it.uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + + // 2. Clear stored reference + getSharedPreferences(Constants.SHARED_PREFS_NAME, Context.MODE_PRIVATE).edit { + remove(Constants.EXTRA_FOLDER_URI) + } } - @SuppressLint("DefaultLocale") - @Suppress("ReplaceJavaStaticMethodWithKotlinAnalog") - fun formatFileSize(size: Long): String { - if (size <= 0) return "0 B" - val units = arrayOf("B", "KB", "MB", "GB", "TB") - val digitGroups = (Math.log10(size.toDouble()) / Math.log10(1024.0)).toInt() - return String.format( - "%.1f %s", - size / Math.pow(1024.0, digitGroups.toDouble()), - units[digitGroups] + fun Long.toReadableFileSize(): String { + if (this <= 0L) return "0 B" + + val units = listOf("B", "KB", "MB", "GB", "TB") + val digitGroup = (ln(this.toDouble()) / ln(1024.0)).toInt() + + return "%.1f %s".format( + this / 1024.0.pow(digitGroup), units[digitGroup] ) } - fun canWriteToUri(context: Context, uri: Uri): Boolean { - val docFile = DocumentFile.fromTreeUri(context, uri) // Or fromSingleUri if it's not a tree - return docFile?.canWrite() == true - } + fun Uri.canWrite(context: Context): Boolean = + DocumentFile.fromTreeUri(context, this)?.canWrite() == true } \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/HapticUtils.kt b/app/src/main/java/com/matanh/transfer/util/HapticUtils.kt new file mode 100644 index 0000000..afdd4d1 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/util/HapticUtils.kt @@ -0,0 +1,31 @@ +package com.matanh.transfer.util + +import android.view.View +import com.matanh.transfer.ui.common.HapticFeedback.longPressHapticFeedback +import com.matanh.transfer.ui.common.HapticFeedback.slightHapticFeedback +import com.matanh.transfer.ui.common.booleanState + +object HapticUtils { + + enum class VibrationType { + Weak, + Strong + } + + fun vibrate(view: View, type: VibrationType) { + if (HAPTICS_VIBRATION.booleanState) { + when (type) { + VibrationType.Weak -> view.slightHapticFeedback() + VibrationType.Strong -> view.longPressHapticFeedback() + } + } + } + + fun weakVibrate(view: View) { + vibrate(view, VibrationType.Weak) + } + + fun strongVibrate(view: View) { + vibrate(view, VibrationType.Strong) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/IpEntryAdapter.kt b/app/src/main/java/com/matanh/transfer/util/IpEntryAdapter.kt index 2f97735..576f7cc 100644 --- a/app/src/main/java/com/matanh/transfer/util/IpEntryAdapter.kt +++ b/app/src/main/java/com/matanh/transfer/util/IpEntryAdapter.kt @@ -1,12 +1,11 @@ package com.matanh.transfer.util -import android.annotation.SuppressLint import android.content.Context import android.widget.ArrayAdapter - import android.view.View import android.view.ViewGroup import android.widget.TextView +import com.matanh.transfer.R /** * A dropdown adapter showing labeled IP entries in the list, but only the raw value when closed. @@ -19,32 +18,31 @@ class IpEntryAdapter( android.R.layout.simple_dropdown_item_1line, entries.toMutableList() ) { - @SuppressLint("SetTextI18n") - fun expand( - tv: TextView, - position: Int, - ): View { - val item = getItem(position) - tv.text = "${item?.label} ${item?.value}" - return tv + + private fun bind(view: TextView, position: Int) { + val item = getItem(position) ?: return + view.text = context.getString( + R.string.ip_entry_format, + item.label, + item.value + ) } + override fun getDropDownView( position: Int, convertView: View?, parent: ViewGroup - ): View { - val tv = super.getDropDownView(position, convertView, parent) as TextView - return expand(tv , position) - } + ): View = + (super.getDropDownView(position, convertView, parent) as TextView).also { + bind(it, position) + } override fun getView( position: Int, convertView: View?, parent: ViewGroup - ): View { - val tv = super.getView(position, convertView, parent) as TextView - return expand(tv , position) - } - -} - + ): View = + (super.getView(position, convertView, parent) as TextView).also { + bind(it, position) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/PreferenceUtil.kt b/app/src/main/java/com/matanh/transfer/util/PreferenceUtil.kt new file mode 100644 index 0000000..c3f4d82 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/util/PreferenceUtil.kt @@ -0,0 +1,58 @@ +package com.matanh.transfer.util + +import androidx.appcompat.app.AppCompatDelegate +import com.tencent.mmkv.MMKV + +const val THEME_MODE = "theme_mode" +const val AMOLED_THEME = "amoled_theme" +const val DYNAMIC_THEME = "dynamic_theme" +const val HAPTICS_VIBRATION = "haptics_vibration" +const val APP_THEME = "app_theme" +const val TEST = "test" + +private val StringPreferenceDefaults = mapOf( + APP_THEME to "system", +) + +private val BooleanPreferenceDefaults = mapOf( + HAPTICS_VIBRATION to true, AMOLED_THEME to false, DYNAMIC_THEME to true +) + + +private val IntPreferenceDefaults = mapOf( + TEST to 0, + THEME_MODE to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, +) + +private val kv: MMKV = MMKV.defaultMMKV() + +object PreferenceUtil { + + fun String.getInt(default: Int = IntPreferenceDefaults.getOrElse(this) { 0 }): Int = + kv.decodeInt(this, default) + + fun String.getString(default: String = StringPreferenceDefaults.getOrElse(this) { "" }): String = + kv.decodeString(this) ?: default + + fun String.getBoolean(default: Boolean = BooleanPreferenceDefaults.getOrElse(this) { false }): Boolean = + kv.decodeBool(this, default) + + fun String.getLong(default: Long) = kv.decodeLong(this, default) + + fun String.updateString(newString: String) = kv.encode(this, newString) + + fun String.updateInt(newInt: Int) = kv.encode(this, newInt) + + fun String.updateLong(newLong: Long) = kv.encode(this, newLong) + + fun String.updateBoolean(newValue: Boolean) = kv.encode(this, newValue) + + fun updateValue(key: String, b: Boolean) = key.updateBoolean(b) + + fun encodeInt(key: String, int: Int) = key.updateInt(int) + + fun encodeString(key: String, string: String) = key.updateString(string) + + fun containsKey(key: String) = kv.containsKey(key) + +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/QRCodeGenerator.kt b/app/src/main/java/com/matanh/transfer/util/QRCodeGenerator.kt index 7adca37..8469ec1 100644 --- a/app/src/main/java/com/matanh/transfer/util/QRCodeGenerator.kt +++ b/app/src/main/java/com/matanh/transfer/util/QRCodeGenerator.kt @@ -1,43 +1,48 @@ package com.matanh.transfer.util -import android.graphics.Bitmap +import android.annotation.SuppressLint +import android.content.Context import android.graphics.Color -import com.google.zxing.BarcodeFormat -import com.google.zxing.EncodeHintType -import com.google.zxing.WriterException -import com.google.zxing.common.BitMatrix -import com.google.zxing.qrcode.QRCodeWriter -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import android.graphics.drawable.Drawable +import com.github.alexzhirkevich.customqrgenerator.QrData +import com.github.alexzhirkevich.customqrgenerator.vector.QrCodeDrawable +import com.github.alexzhirkevich.customqrgenerator.vector.createQrVectorOptions +import com.github.alexzhirkevich.customqrgenerator.vector.style.QrVectorBallShape +import com.github.alexzhirkevich.customqrgenerator.vector.style.QrVectorColor +import com.github.alexzhirkevich.customqrgenerator.vector.style.QrVectorFrameShape +import com.github.alexzhirkevich.customqrgenerator.vector.style.QrVectorPixelShape +import com.google.android.material.color.MaterialColors object QRCodeGenerator { - - /** - * Generates a QR code bitmap for the given URL - * @param url The URL to encode in the QR code - * @param size The size of the QR code in pixels (width and height) - * @return Bitmap of the QR code or null if generation fails - */ - fun generateQRCode(url: String, size: Int = 512): Bitmap? { - return try { - val writer = QRCodeWriter() - val hints = hashMapOf().apply { - put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M) - put(EncodeHintType.MARGIN, 1) + + @SuppressLint("Range") + fun generateQRCode(context: Context, url: String): Drawable { + val data = QrData.Url(url) + + val options = createQrVectorOptions { + + padding = .125f + + colors { + dark = QrVectorColor.Solid( + MaterialColors.getColor( + context, com.google.android.material.R.attr.colorSecondary, Color.BLACK + ) + ) + frame = QrVectorColor.Solid( + MaterialColors.getColor( + context, com.google.android.material.R.attr.colorSecondary, Color.BLACK + ) + ) + } - - val bitMatrix: BitMatrix = writer.encode(url, BarcodeFormat.QR_CODE, size, size, hints) - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565) - - for (x in 0 until size) { - for (y in 0 until size) { - bitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE) - } + shapes { + darkPixel = QrVectorPixelShape.RoundCorners(.5f) + ball = QrVectorBallShape.RoundCorners(1f) + frame = QrVectorFrameShape.RoundCorners(1f) } - - bitmap - } catch (e: WriterException) { - e.printStackTrace() - null } + + return QrCodeDrawable(data, options) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/ShareHandler.kt b/app/src/main/java/com/matanh/transfer/util/ShareHandler.kt index 9591a3a..e531b49 100644 --- a/app/src/main/java/com/matanh/transfer/util/ShareHandler.kt +++ b/app/src/main/java/com/matanh/transfer/util/ShareHandler.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.widget.Toast import com.matanh.transfer.R import com.matanh.transfer.ui.MainViewModel +import com.matanh.transfer.util.FileUtils.getFileName /** * A helper class to process incoming share intents (for text, single files, and multiple files). @@ -56,8 +57,9 @@ class ShareHandler( } private suspend fun handleSharedFile(intent: Intent, folderUri: Uri) { + @Suppress("DEPRECATION") val fileUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) ?: return - val fileName = FileUtils.getFileName(context, fileUri) ?: "shared_file" + val fileName = context.getFileName(fileUri) val copiedFile = FileUtils.copyUriToAppDir(context, fileUri, folderUri, fileName) if (copiedFile != null && copiedFile.exists()) { @@ -76,7 +78,7 @@ class ShareHandler( var successCount = 0 for (uri in uris) { - val fileName = FileUtils.getFileName(context, uri) ?: "file_${System.currentTimeMillis()}" + val fileName = context.getFileName(uri) if (FileUtils.copyUriToAppDir(context, uri, folderUri, fileName) != null) { successCount++ } @@ -91,4 +93,4 @@ class ShareHandler( Toast.makeText(context, R.string.error_saving_shared_content, Toast.LENGTH_SHORT).show() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/ThemeUtil.kt b/app/src/main/java/com/matanh/transfer/util/ThemeUtil.kt new file mode 100644 index 0000000..3da1e93 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/util/ThemeUtil.kt @@ -0,0 +1,54 @@ +package com.matanh.transfer.util + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import com.matanh.transfer.R +import com.matanh.transfer.ui.common.booleanState +import com.matanh.transfer.ui.common.intState + +object ThemeUtil { + private var isAmoledTheme: Boolean = false + private var isDynamicTheme: Boolean = false + private var themeMode: Int = AppCompatDelegate.MODE_NIGHT_NO + + fun updateTheme(activity: AppCompatActivity) { + isAmoledTheme = AMOLED_THEME.booleanState + isDynamicTheme = DYNAMIC_THEME.booleanState + themeMode = THEME_MODE.intState + + AppCompatDelegate.setDefaultNightMode(themeMode) + + if (isAmoledTheme && isNightMode(activity)) { + setHighContrastDarkTheme(activity) + } else { + setNormalTheme(activity) + } + } + + private fun setHighContrastDarkTheme(activity: AppCompatActivity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + activity.setTheme( + if (isDynamicTheme) R.style.TransferApp_AmoledTheme_DynamicColors + else R.style.TransferApp_AmoledTheme + ) + } else { + activity.setTheme(R.style.ThemeOverlay_TransferApp_AmoledThemeBelowV31) + } + } + + private fun setNormalTheme(activity: AppCompatActivity) { + if (isDynamicTheme) { + activity.setTheme(R.style.TransferApp_DynamicColors) + } else { + activity.setTheme(R.style.TransferApp_AppTheme) + } + } + + // Returns if the device is in dark mode + fun isNightMode(context: Context): Boolean { + return (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + } +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/util/TransferApp.kt b/app/src/main/java/com/matanh/transfer/util/TransferApp.kt index 5cec691..c1fdae4 100644 --- a/app/src/main/java/com/matanh/transfer/util/TransferApp.kt +++ b/app/src/main/java/com/matanh/transfer/util/TransferApp.kt @@ -1,25 +1,62 @@ package com.matanh.transfer.util +import android.annotation.SuppressLint import android.app.Application +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.os.Build +import android.os.Handler +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.getSystemService +import com.matanh.transfer.ui.common.intState +import com.tencent.mmkv.MMKV +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import timber.log.Timber class TransferApp : Application() { - companion object { - lateinit var memoryTree: InMemoryLogTree - private set - } override fun onCreate() { super.onCreate() + MMKV.initialize(this) + + context = applicationContext + applicationHandler = Handler(context.mainLooper) + packageInfo = packageManager.run { + if (Build.VERSION.SDK_INT >= 33) getPackageInfo( + packageName, PackageManager.PackageInfoFlags.of(0) + ) + else getPackageInfo(packageName, 0) + } + applicationScope = CoroutineScope(SupervisorJob()) + connectivityManager = getSystemService()!! + + AppCompatDelegate.setDefaultNightMode(THEME_MODE.intState) + // 1) Plant the debug tree Timber.plant(Timber.DebugTree()) - // 2) Plant custom in‑memory tree - memoryTree = InMemoryLogTree() Timber.plant(memoryTree) Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Timber.e(throwable, "Uncaught exception in thread ${thread.name}") } } + + companion object { + lateinit var applicationScope: CoroutineScope + lateinit var connectivityManager: ConnectivityManager + lateinit var packageInfo: PackageInfo + lateinit var applicationHandler: Handler + + val memoryTree: InMemoryLogTree by lazy(LazyThreadSafetyMode.NONE) { + InMemoryLogTree() + } + + @SuppressLint("StaticFieldLeak") + lateinit var context: Context + } + } \ No newline at end of file diff --git a/app/src/main/res/drawable-night/ic_onboarding3.xml b/app/src/main/res/drawable-night/ic_onboarding3.xml new file mode 100644 index 0000000..cb29896 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_onboarding3.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/app_badging.xml b/app/src/main/res/drawable/app_badging.xml new file mode 100644 index 0000000..0beb830 --- /dev/null +++ b/app/src/main/res/drawable/app_badging.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/colorize.xml b/app/src/main/res/drawable/colorize.xml new file mode 100644 index 0000000..41b0e4c --- /dev/null +++ b/app/src/main/res/drawable/colorize.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/contrast.xml b/app/src/main/res/drawable/contrast.xml new file mode 100644 index 0000000..78b243c --- /dev/null +++ b/app/src/main/res/drawable/contrast.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/cookie_shape.xml b/app/src/main/res/drawable/cookie_shape.xml new file mode 100644 index 0000000..d851557 --- /dev/null +++ b/app/src/main/res/drawable/cookie_shape.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/folder.xml b/app/src/main/res/drawable/folder.xml new file mode 100644 index 0000000..a1e978b --- /dev/null +++ b/app/src/main/res/drawable/folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/group.xml b/app/src/main/res/drawable/group.xml new file mode 100644 index 0000000..ade102f --- /dev/null +++ b/app/src/main/res/drawable/group.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/help.xml b/app/src/main/res/drawable/help.xml new file mode 100644 index 0000000..e9e46fd --- /dev/null +++ b/app/src/main/res/drawable/help.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_bmc_logo.xml b/app/src/main/res/drawable/ic_bmc_logo.xml new file mode 100644 index 0000000..4b82007 --- /dev/null +++ b/app/src/main/res/drawable/ic_bmc_logo.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..14dd182 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cross.xml b/app/src/main/res/drawable/ic_cross.xml new file mode 100644 index 0000000..d940a44 --- /dev/null +++ b/app/src/main/res/drawable/ic_cross.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..5d50244 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_onboarding3.xml b/app/src/main/res/drawable/ic_onboarding3.xml new file mode 100644 index 0000000..56f9c15 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding3.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_telegram.xml b/app/src/main/res/drawable/ic_telegram.xml new file mode 100644 index 0000000..1443bb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_telegram.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_undraw_theme.xml b/app/src/main/res/drawable/ic_undraw_theme.xml new file mode 100644 index 0000000..619735d --- /dev/null +++ b/app/src/main/res/drawable/ic_undraw_theme.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/info.xml b/app/src/main/res/drawable/info.xml new file mode 100644 index 0000000..5c795c6 --- /dev/null +++ b/app/src/main/res/drawable/info.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/mobile_vibrate.xml b/app/src/main/res/drawable/mobile_vibrate.xml new file mode 100644 index 0000000..5da620d --- /dev/null +++ b/app/src/main/res/drawable/mobile_vibrate.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/open_in_new.xml b/app/src/main/res/drawable/open_in_new.xml new file mode 100644 index 0000000..5d3077e --- /dev/null +++ b/app/src/main/res/drawable/open_in_new.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/palette.xml b/app/src/main/res/drawable/palette.xml new file mode 100644 index 0000000..75051a4 --- /dev/null +++ b/app/src/main/res/drawable/palette.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/password.xml b/app/src/main/res/drawable/password.xml new file mode 100644 index 0000000..159fc63 --- /dev/null +++ b/app/src/main/res/drawable/password.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/sync.xml b/app/src/main/res/drawable/sync.xml new file mode 100644 index 0000000..5d97b3b --- /dev/null +++ b/app/src/main/res/drawable/sync.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/thumb_drawable.xml b/app/src/main/res/drawable/thumb_drawable.xml new file mode 100644 index 0000000..7e9deaa --- /dev/null +++ b/app/src/main/res/drawable/thumb_drawable.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_shape1.xml b/app/src/main/res/drawable/ui_shape1.xml new file mode 100644 index 0000000..0b46f26 --- /dev/null +++ b/app/src/main/res/drawable/ui_shape1.xml @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_shape2.xml b/app/src/main/res/drawable/ui_shape2.xml new file mode 100644 index 0000000..3f1b675 --- /dev/null +++ b/app/src/main/res/drawable/ui_shape2.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_shape3.xml b/app/src/main/res/drawable/ui_shape3.xml new file mode 100644 index 0000000..54877d8 --- /dev/null +++ b/app/src/main/res/drawable/ui_shape3.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_shape4.xml b/app/src/main/res/drawable/ui_shape4.xml new file mode 100644 index 0000000..d144380 --- /dev/null +++ b/app/src/main/res/drawable/ui_shape4.xml @@ -0,0 +1,21 @@ + + + + diff --git a/app/src/main/res/drawable/ui_shape5.xml b/app/src/main/res/drawable/ui_shape5.xml new file mode 100644 index 0000000..c082174 --- /dev/null +++ b/app/src/main/res/drawable/ui_shape5.xml @@ -0,0 +1,21 @@ + + + + diff --git a/app/src/main/res/font/hanken_grotesk_bold.ttf b/app/src/main/res/font/hanken_grotesk_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..654c4653cbf2f78fb55f5a0aebe11523ea3a88f9 GIT binary patch literal 66508 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 aAqeuOHn%h7q}?D&JnV+s{ug(!?EeCd#7YzZ literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml deleted file mode 100644 index f0b0502..0000000 --- a/app/src/main/res/layout/activity_about.xml +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_about_us.xml b/app/src/main/res/layout/activity_about_us.xml new file mode 100644 index 0000000..6ab9dcd --- /dev/null +++ b/app/src/main/res/layout/activity_about_us.xml @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_look_and_feel.xml b/app/src/main/res/layout/activity_look_and_feel.xml new file mode 100644 index 0000000..b4a93f5 --- /dev/null +++ b/app/src/main/res/layout/activity_look_and_feel.xml @@ -0,0 +1,471 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 deleted file mode 100644 index 7f61aef..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -