Make image and text sharing work

This commit is contained in:
Philipp Heckel 2022-02-11 15:55:08 -05:00
parent 3b30e39eb5
commit 8100e68b8d
7 changed files with 130 additions and 22 deletions

View file

@ -64,11 +64,10 @@
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
</intent-filter> <data android:mimeType="text/*" />
<intent-filter> <data android:mimeType="audio/*" />
<action android:name="android.intent.action.SEND" /> <data android:mimeType="video/*" />
<category android:name="android.intent.category.DEFAULT" /> <data android:mimeType="application/*" />
<data android:mimeType="text/plain" />
</intent-filter> </intent-filter>
</activity> </activity>

View file

@ -1,5 +1,6 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.msg
import android.net.Uri
import android.os.Build import android.os.Build
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
@ -28,12 +29,11 @@ class ApiService {
.build() .build()
private val parser = NotificationParser() private val parser = NotificationParser()
fun publish(baseUrl: String, topic: String, user: User?, message: String, title: String, priority: Int, tags: List<String>, delay: String) { fun publish(baseUrl: String, topic: String, user: User?, message: String, title: String, priority: Int, tags: List<String>, delay: String, body: RequestBody? = null) {
val url = topicUrl(baseUrl, topic) val url = topicUrl(baseUrl, topic)
Log.d(TAG, "Publishing to $url") Log.d(TAG, "Publishing to $url")
val builder = requestBuilder(url, user) val builder = requestBuilder(url, user)
.put(message.toRequestBody())
if (priority in 1..5) { if (priority in 1..5) {
builder.addHeader("X-Priority", priority.toString()) builder.addHeader("X-Priority", priority.toString())
} }
@ -46,6 +46,13 @@ class ApiService {
if (delay.isNotEmpty()) { if (delay.isNotEmpty()) {
builder.addHeader("X-Delay", delay) 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 -> client.newCall(builder.build()).execute().use { response ->
if (response.code == 401 || response.code == 403) { if (response.code == 401 || response.code == 403) {
throw UnauthorizedException(user) throw UnauthorizedException(user)

View file

@ -193,7 +193,7 @@ class AddFragment : DialogFragment() {
subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
// Username/password validation on type // Username/password validation on type
val textWatcher = object : TextWatcher { val loginTextWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
validateInputLoginView() validateInputLoginView()
} }
@ -204,8 +204,8 @@ class AddFragment : DialogFragment() {
// Nothing // Nothing
} }
} }
loginUsernameText.addTextChangedListener(textWatcher) loginUsernameText.addTextChangedListener(loginTextWatcher)
loginPasswordText.addTextChangedListener(textWatcher) loginPasswordText.addTextChangedListener(loginTextWatcher)
// Build dialog // Build dialog
val dialog = AlertDialog.Builder(activity) val dialog = AlertDialog.Builder(activity)
@ -219,7 +219,7 @@ class AddFragment : DialogFragment() {
.create() .create()
// Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785) // 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 // Add logic to disable "Subscribe" button on invalid input
dialog.setOnShowListener { dialog.setOnShowListener {
@ -232,7 +232,7 @@ class AddFragment : DialogFragment() {
negativeButton.setOnClickListener { negativeButton.setOnClickListener {
negativeButtonClick() negativeButtonClick()
} }
val textWatcher = object : TextWatcher { val subscribeTextWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
validateInputSubscribeView() validateInputSubscribeView()
} }
@ -243,8 +243,8 @@ class AddFragment : DialogFragment() {
// Nothing // Nothing
} }
} }
subscribeTopicText.addTextChangedListener(textWatcher) subscribeTopicText.addTextChangedListener(subscribeTextWatcher)
subscribeBaseUrlText.addTextChangedListener(textWatcher) subscribeBaseUrlText.addTextChangedListener(subscribeTextWatcher)
subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, isChecked -> subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) subscribeInstantDeliveryDescription.visibility = View.VISIBLE if (isChecked) subscribeInstantDeliveryDescription.visibility = View.VISIBLE
else subscribeInstantDeliveryDescription.visibility = View.GONE 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") Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog")
val activity = activity ?: return@launch // We may have pressed "Cancel" val activity = activity ?: return@launch // We may have pressed "Cancel"
activity.runOnUiThread { 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() resetLoginView()
loginProgress.visibility = View.INVISIBLE loginProgress.visibility = View.INVISIBLE
positiveButton.text = getString(R.string.add_dialog_button_login) positiveButton.text = getString(R.string.add_dialog_button_login)

View file

@ -14,15 +14,23 @@ import android.widget.ImageView
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.ContentUriRequestBody
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.supportedImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ShareActivity : AppCompatActivity() { class ShareActivity : AppCompatActivity() {
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService() private val api = ApiService()
// File to share
private var fileUri: Uri? = null
// UI elements // UI elements
private lateinit var menu: Menu private lateinit var menu: Menu
private lateinit var sendItem: MenuItem private lateinit var sendItem: MenuItem
@ -30,6 +38,8 @@ class ShareActivity : AppCompatActivity() {
private lateinit var contentText: TextView private lateinit var contentText: TextView
private lateinit var topicText: TextView private lateinit var topicText: TextView
private lateinit var progress: ProgressBar private lateinit var progress: ProgressBar
private lateinit var errorText: TextView
private lateinit var errorImage: ImageView
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -50,6 +60,10 @@ class ShareActivity : AppCompatActivity() {
topicText = findViewById(R.id.share_topic_text) topicText = findViewById(R.id.share_topic_text)
progress = findViewById(R.id.share_progress) progress = findViewById(R.id.share_progress)
progress.visibility = View.GONE 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 { val textWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
@ -62,6 +76,7 @@ class ShareActivity : AppCompatActivity() {
// Nothing // Nothing
} }
} }
contentText.addTextChangedListener(textWatcher)
topicText.addTextChangedListener(textWatcher) topicText.addTextChangedListener(textWatcher)
// Incoming intent // Incoming intent
@ -69,8 +84,10 @@ class ShareActivity : AppCompatActivity() {
if (intent.action != Intent.ACTION_SEND) return if (intent.action != Intent.ACTION_SEND) return
if ("text/plain" == intent.type) { if ("text/plain" == intent.type) {
handleSendText(intent) handleSendText(intent)
} else if (intent.type?.startsWith("image/") == true) { } else if (supportedImage(intent.type)) {
handleSendImage(intent) handleSendImage(intent)
} else {
handleSendFile(intent)
} }
} }
@ -82,19 +99,26 @@ class ShareActivity : AppCompatActivity() {
} }
private fun handleSendImage(intent: Intent) { private fun handleSendImage(intent: Intent) {
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri ?: return fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri ?: return
try { try {
val resolver = applicationContext.contentResolver val resolver = applicationContext.contentResolver
val bitmapStream = resolver.openInputStream(uri) val bitmapStream = resolver.openInputStream(fileUri!!)
val bitmap = BitmapFactory.decodeStream(bitmapStream) val bitmap = BitmapFactory.decodeStream(bitmapStream)
contentImage.setImageBitmap(bitmap) contentImage.setImageBitmap(bitmap)
contentImage.visibility = View.VISIBLE contentImage.visibility = View.VISIBLE
contentText.text = getString(R.string.share_content_image_text) contentText.text = getString(R.string.share_content_image_text)
} catch (_: Exception) { } catch (_: Exception) {
fileUri = null
contentImage.visibility = View.GONE
contentText.text = getString(R.string.share_content_image_error) contentText.text = getString(R.string.share_content_image_error)
} }
} }
private fun handleSendFile(intent: Intent) {
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri ?: return
contentText.text = getString(R.string.share_content_file_text)
}
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
finish() finish()
return true return true
@ -119,7 +143,42 @@ class ShareActivity : AppCompatActivity() {
} }
private fun onShareClick() { 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() { private fun validateInput() {

View file

@ -2,6 +2,7 @@ package io.heckel.ntfy.util
import android.animation.ArgbEvaluator import android.animation.ArgbEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Configuration.UI_MODE_NIGHT_YES 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.Notification
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription 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.security.SecureRandom
import java.text.DateFormat import java.text.DateFormat
import java.text.StringCharacterIterator import java.text.StringCharacterIterator
@ -218,3 +225,22 @@ fun isDarkThemeOn(context: Context): Boolean {
} }
return false 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)
}
}
}

View file

@ -31,7 +31,7 @@
android:scaleType="fitStart" android:scaleType="fitStart"
android:adjustViewBounds="true" android:maxHeight="150dp" android:adjustViewBounds="true" android:maxHeight="150dp"
app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible" 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"/>
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/share_content_text" android:id="@+id/share_content_text"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -58,5 +58,21 @@
android:maxLines="1" android:inputType="text" android:maxLength="64" android:maxLines="1" android:inputType="text" android:maxLength="64"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/share_topic_title"/> app:layout_constraintTop_toBottomOf="@id/share_topic_title"/>
<TextView
android:text="Unable to resolve host example.com"
android:layout_width="0dp"
android:layout_height="wrap_content" android:id="@+id/share_error_text"
android:paddingStart="4dp"
app:layout_constraintEnd_toEndOf="parent"
android:paddingEnd="4dp"
android:textAppearance="@style/DangerText"
app:layout_constraintStart_toEndOf="@id/share_error_image"
android:layout_marginTop="5dp" app:layout_constraintTop_toBottomOf="@+id/share_topic_text"/>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp"
android:id="@+id/share_error_image"
android:visibility="visible"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/share_error_text" android:layout_marginTop="2dp"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -190,8 +190,9 @@
<string name="share_menu_send">Share</string> <string name="share_menu_send">Share</string>
<string name="share_content_title">Message preview</string> <string name="share_content_title">Message preview</string>
<string name="share_content_text_hint">Add the content you\'d like to share here</string> <string name="share_content_text_hint">Add the content you\'d like to share here</string>
<string name="share_content_image_text">An image was shared with you.</string> <string name="share_content_image_text">An image was shared with you</string>
<string name="share_content_image_error">Cannot read image</string> <string name="share_content_image_error">Cannot read image</string>
<string name="share_content_file_text">An file was shared with you</string>
<!-- Notification dialog --> <!-- Notification dialog -->
<string name="notification_dialog_title">Pause notifications</string> <string name="notification_dialog_title">Pause notifications</string>