diff --git a/AGENTS.md b/AGENTS.md index 109b0a3..34757b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,15 @@ Android client for [Django Files Upload Server](https://github.com/django-files/ - `gradle/libs.versions.toml` - Library versions - `Taskfile.yml` - [task](https://github.com/go-task/task) commands +## Android + +- applicationId = "com.djangofiles.djangofiles" Release +- applicationId = "com.djangofiles.djangofiles.dev" Debug + +- minSdk = 26 +- targetSdk = 36 +- compileSdk = 37 + ## Commands ALWAYS use the `task *` commands diff --git a/app/src/main/java/com/djangofiles/djangofiles/ui/dialogs/DialogExtensions.kt b/app/src/main/java/com/djangofiles/djangofiles/ui/dialogs/DialogExtensions.kt new file mode 100644 index 0000000..60511b0 --- /dev/null +++ b/app/src/main/java/com/djangofiles/djangofiles/ui/dialogs/DialogExtensions.kt @@ -0,0 +1,133 @@ +package com.djangofiles.djangofiles.ui.dialogs + +import android.app.Dialog +import android.content.Context +import android.os.Build +import android.os.SystemClock +import android.view.Window +import android.view.WindowInsets +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat + +/** + * Shows the soft keyboard for this dialog window. + * + * Copied from androidx.preference PreferenceDialogFragmentCompat.requestInputMethod() + * which is how EditTextPreference dialogs shows the keyboard when a dialog is shown. + * + * https://github.com/androidx/androidx/blob/androidx-main/preference/preference/src/main/java/androidx/preference/PreferenceDialogFragmentCompat.java + * + * AI NOTE: Call AFTER create() and BEFORE show() (like the library calls requestInputMethod + * in onCreateDialog). The focused editor and window flags must be in place before the + * dialog window gains focus or the keyboard will not show reliably. + * + * IMPORTANT: This should probably be reverted to the simplified version: + * https://github.com/cssnr/zipline-android/blob/master/app/src/main/java/org/cssnr/zipline/ui/dialogs/DialogExtensions.kt + */ +fun Dialog.showKeyboard() { + val window: Window = window ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + slideAboveIme() + // Same as androidx.preference Api30Impl.showIme(window) + window.decorView.windowInsetsController?.show(WindowInsets.Type.ime()) + } else { + // AI NOTE: Below R, WindowInsetsCompat.Type.ime() carries no data, so fall back to the + // legacy system pan behavior there. + // NOTE: SOFT_INPUT_ADJUST_PAN prevents shrinking the dialog + window.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN + ) + + // TODO: Validate code below here - added to show keyboard in landscape in API <30 + // AI NOTE: Port of androidx.preference EditTextPreferenceDialogFragmentCompat + // scheduleShowSoftInputInner(): below Android R, imm.showSoftInput() is + // silently refused while the dialog window has not gained focus yet + // (async gap between show() and focus arriving), so retry every + // SHOW_RETRY_DELAY_MS until the system accepts the request or the + // SHOW_REQUEST_TIMEOUT_MS budget runs out - same values as the library. + val startMs = SystemClock.uptimeMillis() + + fun tryShow() { + val editor = window.currentFocus ?: window.decorView.findFocus() + if (editor != null) { + val imm = editor.context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as InputMethodManager + if (imm.showSoftInput(editor, 0)) { + return + } + } + if (SystemClock.uptimeMillis() - startMs < SHOW_REQUEST_TIMEOUT_MS) { + window.decorView.postDelayed({ tryShow() }, SHOW_RETRY_DELAY_MS) + } + } + + tryShow() + } +} + +// AI NOTE: The dialog keeps its NATURAL SIZE and is TRANSLATED upward into the empty space +// between its top and the top of the screen, until either its bottom edge clears the +// keyboard or its top reaches just below the status bar. This fills the gap above instead +// of shrinking (SOFT_INPUT_ADJUST_RESIZE squashes the whole AlertDialog window frame into +// the leftover strip and makes it unreadable) and instead of panning +// (SOFT_INPUT_ADJUST_PAN only moves the window until the FOCUSED editor clears the top of +// the keyboard - ViewRootImpl scrollY = focusRect.top - visibleTop - which leaves dead +// space above the dialog while the bottom buttons stay covered). +// +// Mechanics: a Dialog has its own Window with its own softInputMode; the activity manifest +// setting never applies to it. ADJUST_NOTHING disables both built-in behaviors so nothing +// fights this manual translation, and setDecorFitsSystemWindows(false) lets the raw ime() +// insets through to the listener. Per AOSP InsetsState.processSource(), ime() insets are +// calculated relative to THIS window's frame, so ime.bottom on the dialog = exactly how +// many pixels of it the keyboard covers. +private fun Dialog.slideAboveIme() { + val window = window ?: return + val decor = window.decorView + WindowCompat.setDecorFitsSystemWindows(window, false) + window.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + ) + ViewCompat.setOnApplyWindowInsetsListener(decor) { v, insets -> + val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + val barsTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top + if (!imeVisible || imeBottom <= 0) { + if (v.translationY != 0f) v.translationY = 0f + return@setOnApplyWindowInsetsListener insets + } + // Post: getLocationOnScreen() can be stale mid-layout during inset dispatch. + // translationY is subtracted back out so repeated callbacks stay anchored to the + // window's untranslated position instead of drifting upward every callback. + v.post { + val location = IntArray(2) + v.getLocationOnScreen(location) + val baseTop = location[1] - v.translationY.toInt() + // Max distance the dialog can move up: down to just below the status bar. + val maxUp = (baseTop - barsTop).coerceAtLeast(0) + val shift = imeBottom.coerceAtMost(maxUp) + if (shift > 0) { + v.translationY = -shift.toFloat() + } else if (v.translationY != 0f) { + v.translationY = 0f + } + } + insets + } + // Re-evaluate when the dialog's own layout changes (e.g. the multiline feedback + // EditText grows between minLines and maxLines while typing). + decor.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + ViewCompat.requestApplyInsets(view) + } +} + +// Same budget as androidx.preference (SHOW_REQUEST_TIMEOUT = 1000). +private const val SHOW_REQUEST_TIMEOUT_MS = 1000L + +// Same retry interval as androidx.preference (postDelayed(..., 50)). +private const val SHOW_RETRY_DELAY_MS = 50L diff --git a/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesBottomSheet.kt b/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesBottomSheet.kt index 9db4047..e1287f1 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesBottomSheet.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesBottomSheet.kt @@ -30,6 +30,7 @@ import com.djangofiles.djangofiles.ServerApi.FileEditRequest import com.djangofiles.djangofiles.copyToClipboard import com.djangofiles.djangofiles.databinding.FragmentFilesBottomBinding import com.djangofiles.djangofiles.db.AlbumDatabase +import com.djangofiles.djangofiles.ui.dialogs.showKeyboard import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -279,7 +280,7 @@ class FilesBottomSheet : BottomSheetDialogFragment() { layout.addView(input) input.setSelection(0, filePassword.length) - MaterialAlertDialogBuilder(requireContext(), R.style.AlertDialogTheme) + val dialog = MaterialAlertDialogBuilder(requireContext(), R.style.AlertDialogTheme) .setView(layout) .setTitle("Set Password") .setIcon(R.drawable.md_key_24) @@ -306,6 +307,8 @@ class FilesBottomSheet : BottomSheetDialogFragment() { } } } - .show() + .create() + dialog.showKeyboard() + dialog.show() } } diff --git a/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesFragment.kt b/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesFragment.kt index 6b96d38..ed45158 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesFragment.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/ui/files/FilesFragment.kt @@ -40,6 +40,7 @@ import com.djangofiles.djangofiles.databinding.FragmentFilesBinding import com.djangofiles.djangofiles.db.AlbumDao import com.djangofiles.djangofiles.db.AlbumDatabase import com.djangofiles.djangofiles.getUserAgent +import com.djangofiles.djangofiles.ui.dialogs.showKeyboard import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -708,7 +709,7 @@ fun Context.showExpireDialog( val preferences = PreferenceManager.getDefaultSharedPreferences(this) val savedUrl = preferences.getString("saved_url", "").toString() - MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) + val dialog = MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme) .setView(layout) .setTitle("Set Expiration") .setIcon(R.drawable.md_timer_24) @@ -731,7 +732,9 @@ fun Context.showExpireDialog( } } } - .show() + .create() + dialog.showKeyboard() + dialog.show() } suspend fun Context.getAlbums(savedUrl: String) { diff --git a/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt b/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt index 96883ba..4472628 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt @@ -9,6 +9,7 @@ import android.view.ViewGroup import android.widget.Toast import androidx.core.net.toUri import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.Fragment @@ -100,6 +101,14 @@ class LoginFragment : Fragment() { binding.hostnameText.requestFocus() binding.hostnameText.setSelection(binding.hostnameText.text.length) + // TODO: Validate this code and NOTE - This is separate from DialogExtensions.kt + // AI NOTE: requestFocus() only places the cursor. The IME must be shown explicitly + // via WindowInsetsController (recommended over InputMethodManager.showSoftInput(), + // which fails silently when the window does not have focus yet). Per docs, show() + // is guaranteed to be scheduled after the window is focused. + WindowCompat.getInsetsController(requireActivity().window, binding.hostnameText) + .show(WindowInsetsCompat.Type.ime()) + val loginFunction = View.OnClickListener { Log.d("loginFunction", "it: ${it.id}") val inputHost = binding.hostnameText.text.toString().trim() diff --git a/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt b/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt index 95e4ae9..5c27caf 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt @@ -34,6 +34,7 @@ import com.djangofiles.djangofiles.api.FeedbackApi import com.djangofiles.djangofiles.db.Server import com.djangofiles.djangofiles.db.ServerDao import com.djangofiles.djangofiles.db.ServerDatabase +import com.djangofiles.djangofiles.ui.dialogs.showKeyboard import com.djangofiles.djangofiles.work.enqueueWorkRequest import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -439,8 +440,11 @@ class SettingsFragment : PreferenceFragmentCompat() { input.error = "Feedback is Required" } } - input.requestFocus() + // NOTE: Since were not showing keyboard (below) do not the focus field? + // input.requestFocus() } + // NOTE: Keyboard can cover the bottom of dialog on small (1280px) devices + //dialog.showKeyboard() dialog.show() } diff --git a/app/src/main/res/layout/dialog_feedback.xml b/app/src/main/res/layout/dialog_feedback.xml index 4fbe679..9975654 100644 --- a/app/src/main/res/layout/dialog_feedback.xml +++ b/app/src/main/res/layout/dialog_feedback.xml @@ -13,8 +13,8 @@