diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 2040e1c2..1adc3701 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -41,7 +41,12 @@ class ApiService { tags: List = emptyList(), delay: String = "", body: RequestBody? = null, - filename: String = "" + filename: String = "", + click: String = "", + attach: String = "", + email: String = "", + call: String = "", + markdown: Boolean = false ) { val url = topicUrl(baseUrl, topic) val query = mutableListOf() @@ -60,6 +65,21 @@ class ApiService { if (filename.isNotEmpty()) { query.add("filename=${URLEncoder.encode(filename, "UTF-8")}") } + if (click.isNotEmpty()) { + query.add("click=${URLEncoder.encode(click, "UTF-8")}") + } + if (attach.isNotEmpty()) { + query.add("attach=${URLEncoder.encode(attach, "UTF-8")}") + } + if (email.isNotEmpty()) { + query.add("email=${URLEncoder.encode(email, "UTF-8")}") + } + if (call.isNotEmpty()) { + query.add("call=${URLEncoder.encode(call, "UTF-8")}") + } + if (markdown) { + query.add("markdown=true") + } if (body != null) { query.add("message=${URLEncoder.encode(message.replace("\n", "\\n"), "UTF-8")}") } diff --git a/app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt new file mode 100644 index 00000000..c756c5f7 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/PriorityAdapter.kt @@ -0,0 +1,57 @@ +package io.heckel.ntfy.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +import io.heckel.ntfy.R + +data class PriorityItem( + val priority: Int, + val label: String, + val iconResId: Int +) + +class PriorityAdapter( + context: Context, + private val items: List +) : ArrayAdapter(context, R.layout.item_priority_dropdown, items) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return createItemView(position, convertView, parent) + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return createItemView(position, convertView, parent) + } + + private fun createItemView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context) + .inflate(R.layout.item_priority_dropdown, parent, false) + + val item = items[position] + val iconView = view.findViewById(R.id.priority_icon) + val textView = view.findViewById(R.id.priority_text) + + iconView.setImageResource(item.iconResId) + textView.text = item.label + + return view + } + + companion object { + fun createPriorityItems(context: Context): List { + return listOf( + PriorityItem(1, context.getString(R.string.publish_dialog_priority_min), R.drawable.ic_priority_1_24dp), + PriorityItem(2, context.getString(R.string.publish_dialog_priority_low), R.drawable.ic_priority_2_24dp), + PriorityItem(3, context.getString(R.string.publish_dialog_priority_default), R.drawable.ic_priority_3_24dp), + PriorityItem(4, context.getString(R.string.publish_dialog_priority_high), R.drawable.ic_priority_4_24dp), + PriorityItem(5, context.getString(R.string.publish_dialog_priority_max), R.drawable.ic_priority_5_24dp) + ) + } + } +} + diff --git a/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt index b8154090..babf7b2c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt @@ -2,19 +2,29 @@ package io.heckel.ntfy.ui import android.app.Dialog import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.provider.OpenableColumns import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager -import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView +import android.widget.CheckBox +import android.widget.ImageButton +import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup import com.google.android.material.textfield.TextInputEditText import io.heckel.ntfy.R import io.heckel.ntfy.db.Repository @@ -24,27 +34,71 @@ import io.heckel.ntfy.util.AfterChangedTextWatcher import io.heckel.ntfy.util.topicShortUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody class PublishFragment : DialogFragment() { private val api = ApiService() private lateinit var repository: Repository + // Toolbar private lateinit var toolbar: MaterialToolbar private lateinit var sendMenuItem: MenuItem + + // Main fields private lateinit var titleText: TextInputEditText private lateinit var messageText: TextInputEditText + private lateinit var markdownCheckbox: CheckBox private lateinit var tagsText: TextInputEditText private lateinit var priorityDropdown: AutoCompleteTextView + + // Chips + private lateinit var chipGroup: ChipGroup + private lateinit var chipClickUrl: Chip + private lateinit var chipEmail: Chip + private lateinit var chipDelay: Chip + private lateinit var chipAttachUrl: Chip + private lateinit var chipAttachFile: Chip + private lateinit var chipPhoneCall: Chip + + // Optional field layouts + private lateinit var clickUrlLayout: View + private lateinit var emailLayout: View + private lateinit var delayLayout: View + private lateinit var attachUrlLayout: View + private lateinit var attachFileLayout: View + private lateinit var phoneCallLayout: View + + // Optional field inputs + private lateinit var clickUrlText: TextInputEditText + private lateinit var emailText: TextInputEditText + private lateinit var delayText: TextInputEditText + private lateinit var attachUrlText: TextInputEditText + private lateinit var attachFilenameText: TextInputEditText + private lateinit var phoneCallText: TextInputEditText + + // Attach file + private lateinit var attachFileButton: MaterialButton + private lateinit var attachFileName: TextView + + // Progress/Error private lateinit var progress: ProgressBar private lateinit var errorText: TextView private lateinit var errorImage: View + private lateinit var docsLink: TextView + // State private var baseUrl: String = "" private var topic: String = "" private var selectedPriority: Int = 3 // Default priority - private var initialMessage: String = "" + private var selectedFileUri: Uri? = null + private var selectedFileName: String = "" + private var selectedFileMimeType: String = "application/octet-stream" + + // File picker + private lateinit var filePickerLauncher: ActivityResultLauncher interface PublishListener { fun onPublished() @@ -59,6 +113,17 @@ class PublishFragment : DialogFragment() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + filePickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + result.data?.data?.let { uri -> + handleSelectedFile(uri) + } + } + } + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { if (activity == null) { throw IllegalStateException("Activity cannot be null") @@ -91,33 +156,75 @@ class PublishFragment : DialogFragment() { } sendMenuItem = toolbar.menu.findItem(R.id.publish_dialog_send_button) - // Fields + // Main fields titleText = view.findViewById(R.id.publish_dialog_title_text) messageText = view.findViewById(R.id.publish_dialog_message_text) + markdownCheckbox = view.findViewById(R.id.publish_dialog_markdown_checkbox) tagsText = view.findViewById(R.id.publish_dialog_tags_text) priorityDropdown = view.findViewById(R.id.publish_dialog_priority_dropdown) progress = view.findViewById(R.id.publish_dialog_progress) errorText = view.findViewById(R.id.publish_dialog_error_text) errorImage = view.findViewById(R.id.publish_dialog_error_image) + docsLink = view.findViewById(R.id.publish_dialog_docs_link) // Set initial message if provided if (initialMessage.isNotEmpty()) { messageText.setText(initialMessage) } - // Setup priority dropdown - val priorities = listOf( - getString(R.string.publish_dialog_priority_min), - getString(R.string.publish_dialog_priority_low), - getString(R.string.publish_dialog_priority_default), - getString(R.string.publish_dialog_priority_high), - getString(R.string.publish_dialog_priority_max) - ) - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, priorities) - priorityDropdown.setAdapter(adapter) - priorityDropdown.setText(priorities[2], false) // Default priority + // Setup priority dropdown with custom adapter + val priorityItems = PriorityAdapter.createPriorityItems(requireContext()) + val priorityAdapter = PriorityAdapter(requireContext(), priorityItems) + priorityDropdown.setAdapter(priorityAdapter) + priorityDropdown.setText(priorityItems[2].label, false) // Default priority (index 2 = priority 3) priorityDropdown.setOnItemClickListener { _, _, position, _ -> - selectedPriority = position + 1 // Priority is 1-5 + selectedPriority = priorityItems[position].priority + } + + // Setup chips + chipGroup = view.findViewById(R.id.publish_dialog_chip_group) + chipClickUrl = view.findViewById(R.id.publish_dialog_chip_click_url) + chipEmail = view.findViewById(R.id.publish_dialog_chip_email) + chipDelay = view.findViewById(R.id.publish_dialog_chip_delay) + chipAttachUrl = view.findViewById(R.id.publish_dialog_chip_attach_url) + chipAttachFile = view.findViewById(R.id.publish_dialog_chip_attach_file) + chipPhoneCall = view.findViewById(R.id.publish_dialog_chip_phone_call) + + // Setup optional field layouts + clickUrlLayout = view.findViewById(R.id.publish_dialog_click_url_layout) + emailLayout = view.findViewById(R.id.publish_dialog_email_layout) + delayLayout = view.findViewById(R.id.publish_dialog_delay_layout) + attachUrlLayout = view.findViewById(R.id.publish_dialog_attach_url_layout) + attachFileLayout = view.findViewById(R.id.publish_dialog_attach_file_layout) + phoneCallLayout = view.findViewById(R.id.publish_dialog_phone_call_layout) + + // Setup optional field inputs + clickUrlText = view.findViewById(R.id.publish_dialog_click_url_text) + emailText = view.findViewById(R.id.publish_dialog_email_text) + delayText = view.findViewById(R.id.publish_dialog_delay_text) + attachUrlText = view.findViewById(R.id.publish_dialog_attach_url_text) + attachFilenameText = view.findViewById(R.id.publish_dialog_attach_filename_text) + phoneCallText = view.findViewById(R.id.publish_dialog_phone_call_text) + + // Attach file UI + attachFileButton = view.findViewById(R.id.publish_dialog_attach_file_button) + attachFileName = view.findViewById(R.id.publish_dialog_attach_file_name) + + // Setup chip click listeners + setupChipListeners() + + // Setup remove button listeners + setupRemoveButtonListeners(view) + + // Setup file picker button + attachFileButton.setOnClickListener { + openFilePicker() + } + + // Setup docs link + docsLink.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://docs.ntfy.sh/publish/")) + startActivity(intent) } // Validation on text change @@ -136,6 +243,96 @@ class PublishFragment : DialogFragment() { return dialog } + private fun setupChipListeners() { + chipClickUrl.setOnCheckedChangeListener { _, isChecked -> + clickUrlLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (!isChecked) clickUrlText.setText("") + } + + chipEmail.setOnCheckedChangeListener { _, isChecked -> + emailLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (!isChecked) emailText.setText("") + } + + chipDelay.setOnCheckedChangeListener { _, isChecked -> + delayLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (!isChecked) delayText.setText("") + } + + chipAttachUrl.setOnCheckedChangeListener { _, isChecked -> + attachUrlLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + // Mutually exclusive with attach file + chipAttachFile.isChecked = false + } + if (!isChecked) { + attachUrlText.setText("") + attachFilenameText.setText("") + } + } + + chipAttachFile.setOnCheckedChangeListener { _, isChecked -> + attachFileLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (isChecked) { + // Mutually exclusive with attach URL + chipAttachUrl.isChecked = false + } + if (!isChecked) { + selectedFileUri = null + selectedFileName = "" + attachFileName.text = "" + } + } + + chipPhoneCall.setOnCheckedChangeListener { _, isChecked -> + phoneCallLayout.visibility = if (isChecked) View.VISIBLE else View.GONE + if (!isChecked) phoneCallText.setText("") + } + } + + private fun setupRemoveButtonListeners(view: View) { + view.findViewById(R.id.publish_dialog_click_url_remove).setOnClickListener { + chipClickUrl.isChecked = false + } + view.findViewById(R.id.publish_dialog_email_remove).setOnClickListener { + chipEmail.isChecked = false + } + view.findViewById(R.id.publish_dialog_delay_remove).setOnClickListener { + chipDelay.isChecked = false + } + view.findViewById(R.id.publish_dialog_attach_url_remove).setOnClickListener { + chipAttachUrl.isChecked = false + } + view.findViewById(R.id.publish_dialog_attach_file_remove).setOnClickListener { + chipAttachFile.isChecked = false + } + view.findViewById(R.id.publish_dialog_phone_call_remove).setOnClickListener { + chipPhoneCall.isChecked = false + } + } + + private fun openFilePicker() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + filePickerLauncher.launch(intent) + } + + private fun handleSelectedFile(uri: Uri) { + selectedFileUri = uri + + // Get file name and mime type + requireContext().contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + selectedFileName = if (nameIndex >= 0) cursor.getString(nameIndex) else "file" + } + + selectedFileMimeType = requireContext().contentResolver.getType(uri) ?: "application/octet-stream" + attachFileName.text = selectedFileName + } + override fun onStart() { super.onStart() dialog?.window?.apply { @@ -164,6 +361,7 @@ class PublishFragment : DialogFragment() { private fun onSendClick() { val title = titleText.text.toString() val message = messageText.text.toString() + val markdown = markdownCheckbox.isChecked val tagsString = tagsText.text.toString() val tags = if (tagsString.isNotEmpty()) { tagsString.split(",").map { it.trim() }.filter { it.isNotEmpty() } @@ -171,6 +369,14 @@ class PublishFragment : DialogFragment() { emptyList() } + // Optional fields + val clickUrl = if (chipClickUrl.isChecked) clickUrlText.text.toString() else "" + val email = if (chipEmail.isChecked) emailText.text.toString() else "" + val delay = if (chipDelay.isChecked) delayText.text.toString() else "" + val attachUrl = if (chipAttachUrl.isChecked) attachUrlText.text.toString() else "" + val attachFilename = if (chipAttachUrl.isChecked) attachFilenameText.text.toString() else "" + val phoneCall = if (chipPhoneCall.isChecked) phoneCallText.text.toString() else "" + progress.visibility = View.VISIBLE errorText.visibility = View.GONE errorImage.visibility = View.GONE @@ -179,16 +385,52 @@ class PublishFragment : DialogFragment() { lifecycleScope.launch(Dispatchers.IO) { try { val user = repository.getUser(baseUrl) - api.publish( - baseUrl = baseUrl, - topic = topic, - user = user, - message = message, - title = title, - priority = selectedPriority, - tags = tags, - delay = "" - ) + + // Handle file attachment + if (chipAttachFile.isChecked && selectedFileUri != null) { + // Read file and send as body + val inputStream = requireContext().contentResolver.openInputStream(selectedFileUri!!) + val bytes = inputStream?.readBytes() ?: ByteArray(0) + inputStream?.close() + + val body = bytes.toRequestBody(selectedFileMimeType.toMediaType()) + + api.publish( + baseUrl = baseUrl, + topic = topic, + user = user, + message = message, + title = title, + priority = selectedPriority, + tags = tags, + delay = delay, + body = body, + filename = selectedFileName, + click = clickUrl, + email = email, + call = phoneCall, + markdown = markdown + ) + } else { + // No file attachment + api.publish( + baseUrl = baseUrl, + topic = topic, + user = user, + message = message, + title = title, + priority = selectedPriority, + tags = tags, + delay = delay, + click = clickUrl, + attach = attachUrl, + email = email, + call = phoneCall, + markdown = markdown, + filename = attachFilename + ) + } + val activity = activity ?: return@launch activity.runOnUiThread { Toast.makeText(activity, R.string.publish_dialog_message_published, Toast.LENGTH_SHORT).show() @@ -227,8 +469,27 @@ class PublishFragment : DialogFragment() { private fun enableView(enable: Boolean) { titleText.isEnabled = enable messageText.isEnabled = enable + markdownCheckbox.isEnabled = enable tagsText.isEnabled = enable priorityDropdown.isEnabled = enable + + // Chips + chipClickUrl.isEnabled = enable + chipEmail.isEnabled = enable + chipDelay.isEnabled = enable + chipAttachUrl.isEnabled = enable + chipAttachFile.isEnabled = enable + chipPhoneCall.isEnabled = enable + + // Optional fields + clickUrlText.isEnabled = enable + emailText.isEnabled = enable + delayText.isEnabled = enable + attachUrlText.isEnabled = enable + attachFilenameText.isEnabled = enable + phoneCallText.isEnabled = enable + attachFileButton.isEnabled = enable + sendMenuItem.isEnabled = enable && messageText.text?.isNotEmpty() == true } @@ -249,4 +510,3 @@ class PublishFragment : DialogFragment() { } } } - diff --git a/app/src/main/res/drawable/ic_priority_3_24dp.xml b/app/src/main/res/drawable/ic_priority_3_24dp.xml new file mode 100644 index 00000000..e0787be3 --- /dev/null +++ b/app/src/main/res/drawable/ic_priority_3_24dp.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_publish_dialog.xml b/app/src/main/res/layout/fragment_publish_dialog.xml index 6b89da5c..388c4419 100644 --- a/app/src/main/res/layout/fragment_publish_dialog.xml +++ b/app/src/main/res/layout/fragment_publish_dialog.xml @@ -39,30 +39,34 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - + + + + + + + + android:layout_marginTop="8dp"> + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + android:orientation="horizontal" + android:gravity="center_vertical"> - + - + + + + + + + + + + + + + + + + + - - - - - - - - + android:gravity="center_vertical"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/app/src/main/res/layout/item_priority_dropdown.xml b/app/src/main/res/layout/item_priority_dropdown.xml new file mode 100644 index 00000000..165f6ad3 --- /dev/null +++ b/app/src/main/res/layout/item_priority_dropdown.xml @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2c9ed5a..fbe0803b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,7 +203,7 @@ Publish to %1$s Title (optional) Message - Tags (optional, comma-separated) + Tags Default priority Min priority Low priority @@ -213,6 +213,23 @@ Send Cannot send message: %1$s Message published + Format as Markdown + Other features: + Click URL + Email + Delay + Attach by URL + Attach file + Phone call + URL to open when notification is clicked + Email address to forward to + Delay, e.g. 30m, 1h, tomorrow 9am + Attachment URL + Filename + Phone number to call + Pick file + Remove field + For examples and a detailed description of all send features, please refer to the documentation. Type a message here