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 0000000..654c465 Binary files /dev/null and b/app/src/main/res/font/hanken_grotesk_bold.ttf differ 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -