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