diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 877c1f9..066e32e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,11 +64,10 @@ - - - - - + + + + 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 2b20aac..89ce1b8 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -1,5 +1,6 @@ package io.heckel.ntfy.msg +import android.net.Uri import android.os.Build import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.db.Notification @@ -28,12 +29,11 @@ class ApiService { .build() private val parser = NotificationParser() - fun publish(baseUrl: String, topic: String, user: User?, message: String, title: String, priority: Int, tags: List, delay: String) { + fun publish(baseUrl: String, topic: String, user: User?, message: String, title: String, priority: Int, tags: List, delay: String, body: RequestBody? = null) { val url = topicUrl(baseUrl, topic) Log.d(TAG, "Publishing to $url") val builder = requestBuilder(url, user) - .put(message.toRequestBody()) if (priority in 1..5) { builder.addHeader("X-Priority", priority.toString()) } @@ -46,6 +46,13 @@ class ApiService { if (delay.isNotEmpty()) { builder.addHeader("X-Delay", delay) } + if (body != null) { + builder + .addHeader("X-Message", message) + .put(body) + } else { + builder.put(message.toRequestBody()) + } client.newCall(builder.build()).execute().use { response -> if (response.code == 401 || response.code == 403) { throw UnauthorizedException(user) diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 06675c9..f32c0d2 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -193,7 +193,7 @@ class AddFragment : DialogFragment() { subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE // Username/password validation on type - val textWatcher = object : TextWatcher { + val loginTextWatcher = object : TextWatcher { override fun afterTextChanged(s: Editable?) { validateInputLoginView() } @@ -204,8 +204,8 @@ class AddFragment : DialogFragment() { // Nothing } } - loginUsernameText.addTextChangedListener(textWatcher) - loginPasswordText.addTextChangedListener(textWatcher) + loginUsernameText.addTextChangedListener(loginTextWatcher) + loginPasswordText.addTextChangedListener(loginTextWatcher) // Build dialog val dialog = AlertDialog.Builder(activity) @@ -219,7 +219,7 @@ class AddFragment : DialogFragment() { .create() // Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785) - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) // Add logic to disable "Subscribe" button on invalid input dialog.setOnShowListener { @@ -232,7 +232,7 @@ class AddFragment : DialogFragment() { negativeButton.setOnClickListener { negativeButtonClick() } - val textWatcher = object : TextWatcher { + val subscribeTextWatcher = object : TextWatcher { override fun afterTextChanged(s: Editable?) { validateInputSubscribeView() } @@ -243,8 +243,8 @@ class AddFragment : DialogFragment() { // Nothing } } - subscribeTopicText.addTextChangedListener(textWatcher) - subscribeBaseUrlText.addTextChangedListener(textWatcher) + subscribeTopicText.addTextChangedListener(subscribeTextWatcher) + subscribeBaseUrlText.addTextChangedListener(subscribeTextWatcher) subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, isChecked -> if (isChecked) subscribeInstantDeliveryDescription.visibility = View.VISIBLE else subscribeInstantDeliveryDescription.visibility = View.GONE @@ -303,7 +303,7 @@ class AddFragment : DialogFragment() { Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog") val activity = activity ?: return@launch // We may have pressed "Cancel" activity.runOnUiThread { - showLoginView(activity, baseUrl) + showLoginView(activity) } } } @@ -444,7 +444,7 @@ class AddFragment : DialogFragment() { } } - private fun showLoginView(activity: Activity, baseUrl: String) { + private fun showLoginView(activity: Activity) { resetLoginView() loginProgress.visibility = View.INVISIBLE positiveButton.text = getString(R.string.add_dialog_button_login) diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt index 9a4fcb1..b66a5e0 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt @@ -14,15 +14,23 @@ import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.msg.ApiService +import io.heckel.ntfy.util.ContentUriRequestBody import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.supportedImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class ShareActivity : AppCompatActivity() { private val repository by lazy { (application as Application).repository } private val api = ApiService() + // File to share + private var fileUri: Uri? = null + // UI elements private lateinit var menu: Menu private lateinit var sendItem: MenuItem @@ -30,6 +38,8 @@ class ShareActivity : AppCompatActivity() { private lateinit var contentText: TextView private lateinit var topicText: TextView private lateinit var progress: ProgressBar + private lateinit var errorText: TextView + private lateinit var errorImage: ImageView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -50,6 +60,10 @@ class ShareActivity : AppCompatActivity() { topicText = findViewById(R.id.share_topic_text) progress = findViewById(R.id.share_progress) progress.visibility = View.GONE + errorText = findViewById(R.id.share_error_text) + errorText.visibility = View.GONE + errorImage = findViewById(R.id.share_error_image) + errorImage.visibility = View.GONE val textWatcher = object : TextWatcher { override fun afterTextChanged(s: Editable?) { @@ -62,6 +76,7 @@ class ShareActivity : AppCompatActivity() { // Nothing } } + contentText.addTextChangedListener(textWatcher) topicText.addTextChangedListener(textWatcher) // Incoming intent @@ -69,8 +84,10 @@ class ShareActivity : AppCompatActivity() { if (intent.action != Intent.ACTION_SEND) return if ("text/plain" == intent.type) { handleSendText(intent) - } else if (intent.type?.startsWith("image/") == true) { + } else if (supportedImage(intent.type)) { handleSendImage(intent) + } else { + handleSendFile(intent) } } @@ -82,19 +99,26 @@ class ShareActivity : AppCompatActivity() { } private fun handleSendImage(intent: Intent) { - val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri ?: return + fileUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri ?: return try { val resolver = applicationContext.contentResolver - val bitmapStream = resolver.openInputStream(uri) + val bitmapStream = resolver.openInputStream(fileUri!!) val bitmap = BitmapFactory.decodeStream(bitmapStream) contentImage.setImageBitmap(bitmap) contentImage.visibility = View.VISIBLE contentText.text = getString(R.string.share_content_image_text) } catch (_: Exception) { + fileUri = null + contentImage.visibility = View.GONE contentText.text = getString(R.string.share_content_image_error) } } + private fun handleSendFile(intent: Intent) { + fileUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri ?: return + contentText.text = getString(R.string.share_content_file_text) + } + override fun onSupportNavigateUp(): Boolean { finish() return true @@ -119,7 +143,42 @@ class ShareActivity : AppCompatActivity() { } private fun onShareClick() { - + val baseUrl = "https://ntfy.sh" // FIXME + val topic = topicText.text.toString() + val message = contentText.text.toString() + progress.visibility = View.VISIBLE + lifecycleScope.launch(Dispatchers.IO) { + val user = repository.getUser(baseUrl) + try { + val body = if (fileUri != null) { + val resolver = applicationContext.contentResolver + ContentUriRequestBody(resolver, fileUri!!) + } else { + null + } + api.publish( + baseUrl = baseUrl, + topic = topic, + user = user, + message = message, + title = "", + priority = 3, + tags = emptyList(), + delay = "", + body = body // May be null + ) + runOnUiThread { + finish() + } + } catch (e: Exception) { + runOnUiThread { + progress.visibility = View.GONE + errorText.text = e.message + errorImage.visibility = View.VISIBLE + errorText.visibility = View.VISIBLE + } + } + } } private fun validateInput() { diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 4220041..5b87904 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -2,6 +2,7 @@ package io.heckel.ntfy.util import android.animation.ArgbEvaluator import android.animation.ValueAnimator +import android.content.ContentResolver import android.content.Context import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES @@ -14,6 +15,12 @@ import androidx.appcompat.app.AppCompatDelegate import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.IOException import java.security.SecureRandom import java.text.DateFormat import java.text.StringCharacterIterator @@ -218,3 +225,22 @@ fun isDarkThemeOn(context: Context): Boolean { } return false } + +// https://cketti.de/2020/05/23/content-uris-and-okhttp/ +class ContentUriRequestBody( + private val contentResolver: ContentResolver, + private val contentUri: Uri +) : RequestBody() { + + override fun contentType(): MediaType? { + val contentType = contentResolver.getType(contentUri) + return contentType?.toMediaTypeOrNull() + } + + override fun writeTo(sink: BufferedSink) { + val inputStream = contentResolver.openInputStream(contentUri) ?: throw IOException("Couldn't open content URI for reading") + inputStream.source().use { source -> + sink.writeAll(source) + } + } +} diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml index be596ce..c50190f 100644 --- a/app/src/main/res/layout/activity_share.xml +++ b/app/src/main/res/layout/activity_share.xml @@ -31,7 +31,7 @@ android:scaleType="fitStart" android:adjustViewBounds="true" android:maxHeight="150dp" app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible" - app:layout_constraintTop_toBottomOf="@id/share_content_title" android:layout_marginTop="5dp" android:layout_marginStart="5dp"/> + app:layout_constraintTop_toBottomOf="@id/share_content_title" android:layout_marginStart="3dp"/> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28fa3d7..38ebed8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,8 +190,9 @@ Share Message preview Add the content you\'d like to share here - An image was shared with you. + An image was shared with you Cannot read image + An file was shared with you Pause notifications