WIP; but now I actually am starting to understand what's going on
This commit is contained in:
parent
a123edbd8e
commit
63fff52fcf
7 changed files with 197 additions and 62 deletions
|
@ -63,6 +63,7 @@ dependencies {
|
||||||
implementation "androidx.core:core-ktx:$rootProject.coreKtxVersion"
|
implementation "androidx.core:core-ktx:$rootProject.coreKtxVersion"
|
||||||
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
|
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
|
||||||
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
|
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
|
||||||
|
implementation "androidx.fragment:fragment-ktx:$rootProject.fragmentVersion"
|
||||||
implementation 'com.google.code.gson:gson:2.8.8'
|
implementation 'com.google.code.gson:gson:2.8.8'
|
||||||
|
|
||||||
// WorkManager
|
// WorkManager
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".app.Application"
|
android:name=".app.Application"
|
||||||
|
|
|
@ -42,9 +42,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
||||||
.map { Pair(it.id, it.instant) }.toSet()
|
.map { Pair(it.id, it.instant) }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
fun getSubscription(subscriptionId: Long): Subscription? {
|
||||||
@WorkerThread
|
|
||||||
suspend fun getSubscription(subscriptionId: Long): Subscription? {
|
|
||||||
return toSubscription(subscriptionDao.get(subscriptionId))
|
return toSubscription(subscriptionDao.get(subscriptionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
package io.heckel.ntfy.msg
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import io.heckel.ntfy.app.Application
|
||||||
|
import io.heckel.ntfy.data.Notification
|
||||||
|
import io.heckel.ntfy.data.Subscription
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentDownloadWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
private val notifier = NotificationService(context)
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
if (context.applicationContext !is Application) return Result.failure()
|
||||||
|
val notificationId = inputData.getString("id") ?: return Result.failure()
|
||||||
|
val app = context.applicationContext as Application
|
||||||
|
val notification = app.repository.getNotification(notificationId) ?: return Result.failure()
|
||||||
|
val subscription = app.repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
|
||||||
|
if (notification.attachmentPreviewUrl != null) {
|
||||||
|
downloadPreview(subscription, notification)
|
||||||
|
}
|
||||||
|
downloadAttachment(subscription, notification)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadPreview(subscription: Subscription, notification: Notification) {
|
||||||
|
val url = notification.attachmentPreviewUrl ?: return
|
||||||
|
Log.d(TAG, "Downloading preview from $url")
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("User-Agent", ApiService.USER_AGENT)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
throw Exception("Preview download failed: ${response.code}")
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Preview download: successful response, proceeding with download")
|
||||||
|
/*val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
Log.d(TAG, "dir: $dir")
|
||||||
|
if (dir == null /*|| !dir.mkdirs()*/) {
|
||||||
|
throw Exception("Cannot access target storage dir")
|
||||||
|
}*/
|
||||||
|
val contentResolver = applicationContext.contentResolver
|
||||||
|
val contentValues = ContentValues()
|
||||||
|
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "flower.jpg")
|
||||||
|
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||||
|
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
val uri = contentResolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), contentValues)
|
||||||
|
?: throw Exception("Cannot get content URI")
|
||||||
|
val out = contentResolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
|
||||||
|
out.use { fileOut ->
|
||||||
|
response.body!!.byteStream().copyTo(fileOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
val file = File(context.cacheDir, "somefile")
|
||||||
|
context.openFileOutput(file.absolutePath, Context.MODE_PRIVATE).use { fileOut ->
|
||||||
|
response.body!!.byteStream().copyTo(fileOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(dir, "myfile.txt")
|
||||||
|
Log.d(TAG, "dir: $dir, file: $file")
|
||||||
|
FileOutputStream(file).use { fileOut ->
|
||||||
|
response.body!!.byteStream().copyTo(fileOut)
|
||||||
|
}*/
|
||||||
|
/*
|
||||||
|
context.openFileOutput(file.absolutePath, Context.MODE_PRIVATE).use { fileOut ->
|
||||||
|
response.body!!.byteStream().copyTo(fileOut)
|
||||||
|
}*/
|
||||||
|
//val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
Log.d(TAG, "now we would display the preview image")
|
||||||
|
//displayInternal(subscription, notification, bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadAttachment(subscription: Subscription, notification: Notification) {
|
||||||
|
val url = notification.attachmentUrl ?: return
|
||||||
|
Log.d(TAG, "Downloading attachment from $url")
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("User-Agent", ApiService.USER_AGENT)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
throw Exception("Attachment download failed: ${response.code}")
|
||||||
|
}
|
||||||
|
val name = notification.attachmentName ?: "attachment.bin"
|
||||||
|
val mimeType = notification.attachmentType ?: "application/octet-stream"
|
||||||
|
val resolver = applicationContext.contentResolver
|
||||||
|
val details = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
put(MediaStore.MediaColumns.IS_DOWNLOAD, 1)
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), details)
|
||||||
|
?: throw Exception("Cannot get content URI")
|
||||||
|
Log.d(TAG, "Starting download to content URI: $uri")
|
||||||
|
val out = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
|
||||||
|
out.use { fileOut ->
|
||||||
|
response.body!!.byteStream().copyTo(fileOut)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Attachment download: successful response, proceeding with download")
|
||||||
|
notifier.update(subscription, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NtfyAttachDownload"
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,14 +7,15 @@ import android.app.TaskStackBuilder
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.media.RingtoneManager
|
import android.media.RingtoneManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.workDataOf
|
||||||
import io.heckel.ntfy.R
|
import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.data.Notification
|
import io.heckel.ntfy.data.Notification
|
||||||
import io.heckel.ntfy.data.Subscription
|
import io.heckel.ntfy.data.Subscription
|
||||||
|
@ -22,28 +23,27 @@ import io.heckel.ntfy.ui.DetailActivity
|
||||||
import io.heckel.ntfy.ui.MainActivity
|
import io.heckel.ntfy.ui.MainActivity
|
||||||
import io.heckel.ntfy.util.formatMessage
|
import io.heckel.ntfy.util.formatMessage
|
||||||
import io.heckel.ntfy.util.formatTitle
|
import io.heckel.ntfy.util.formatTitle
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class NotificationService(val context: Context) {
|
class NotificationService(val context: Context) {
|
||||||
private val client = OkHttpClient.Builder()
|
|
||||||
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
|
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
fun display(subscription: Subscription, notification: Notification) {
|
fun display(subscription: Subscription, notification: Notification) {
|
||||||
Log.d(TAG, "Displaying notification $notification")
|
Log.d(TAG, "Displaying notification $notification")
|
||||||
|
|
||||||
|
// Display notification immediately
|
||||||
displayInternal(subscription, notification)
|
displayInternal(subscription, notification)
|
||||||
if (notification.attachmentPreviewUrl != null) {
|
|
||||||
downloadPreviewAndUpdate(subscription, notification)
|
// Download attachment (+ preview if available) in the background via WorkManager
|
||||||
|
// The indirection via WorkManager is required since this code may be executed
|
||||||
|
// in a doze state and Internet may not be available. It's also best practice apparently.
|
||||||
|
if (notification.attachmentUrl != null) {
|
||||||
|
scheduleAttachmentDownload(subscription, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun update(subscription: Subscription, notification: Notification) {
|
||||||
|
Log.d(TAG, "Updating notification $notification")
|
||||||
|
displayInternal(subscription, notification)
|
||||||
|
}
|
||||||
|
|
||||||
fun cancel(notification: Notification) {
|
fun cancel(notification: Notification) {
|
||||||
if (notification.notificationId != 0) {
|
if (notification.notificationId != 0) {
|
||||||
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
|
Log.d(TAG, "Cancelling notification ${notification.id}: ${notification.message}")
|
||||||
|
@ -73,8 +73,9 @@ class NotificationService(val context: Context) {
|
||||||
|
|
||||||
if (notification.attachmentUrl != null) {
|
if (notification.attachmentUrl != null) {
|
||||||
val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0)
|
val viewIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse(notification.attachmentUrl)), 0)
|
||||||
|
val openIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW, Uri.parse("content://media/external/file/39")), 0)
|
||||||
notificationBuilder
|
notificationBuilder
|
||||||
.addAction(NotificationCompat.Action.Builder(0, "Open", viewIntent).build())
|
.addAction(NotificationCompat.Action.Builder(0, "Open", openIntent).build())
|
||||||
.addAction(NotificationCompat.Action.Builder(0, "Copy URL", viewIntent).build())
|
.addAction(NotificationCompat.Action.Builder(0, "Copy URL", viewIntent).build())
|
||||||
.addAction(NotificationCompat.Action.Builder(0, "Download", viewIntent).build())
|
.addAction(NotificationCompat.Action.Builder(0, "Download", viewIntent).build())
|
||||||
}
|
}
|
||||||
|
@ -93,23 +94,15 @@ class NotificationService(val context: Context) {
|
||||||
notificationManager.notify(notification.notificationId, notificationBuilder.build())
|
notificationManager.notify(notification.notificationId, notificationBuilder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadPreviewAndUpdate(subscription: Subscription, notification: Notification) {
|
private fun scheduleAttachmentDownload(subscription: Subscription, notification: Notification) {
|
||||||
val previewUrl = notification.attachmentPreviewUrl ?: return
|
Log.d(TAG, "Enqueuing work to download attachment (+ preview if available)")
|
||||||
Log.d(TAG, "Downloading preview image $previewUrl")
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
val workRequest = OneTimeWorkRequest.Builder(AttachmentDownloadWorker::class.java)
|
||||||
val request = Request.Builder()
|
.setInputData(workDataOf(
|
||||||
.url(previewUrl)
|
"id" to notification.id,
|
||||||
.addHeader("User-Agent", ApiService.USER_AGENT)
|
))
|
||||||
.build()
|
.build()
|
||||||
client.newCall(request).execute().use { response ->
|
workManager.enqueue(workRequest)
|
||||||
if (!response.isSuccessful || response.body == null) {
|
|
||||||
Log.d(TAG, "Preview response failed: ${response.code}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Successful response, streaming preview")
|
|
||||||
val bitmap = BitmapFactory.decodeStream(response.body!!.byteStream())
|
|
||||||
displayInternal(subscription, notification, bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setContentIntent(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification): NotificationCompat.Builder? {
|
private fun setContentIntent(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification): NotificationCompat.Builder? {
|
||||||
|
@ -125,32 +118,6 @@ class NotificationService(val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadPreviewAndUpdateXXX(subscription: Subscription, notification: Notification) {
|
|
||||||
val url = notification.attachmentUrl ?: return
|
|
||||||
Log.d(TAG, "Downloading attachment from $url")
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.addHeader("User-Agent", ApiService.USER_AGENT)
|
|
||||||
.build()
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful || response.body == null) {
|
|
||||||
Log.d(TAG, "Attachment download failed: ${response.code}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Successful response")
|
|
||||||
/*val filename = notification.id
|
|
||||||
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS + "/ntfy/" + notification.id)
|
|
||||||
context.openFileOutput(filename, Context.MODE_PRIVATE).use {
|
|
||||||
response.body!!.byteStream()
|
|
||||||
}*/
|
|
||||||
// TODO work manager
|
|
||||||
|
|
||||||
val bitmap = BitmapFactory.decodeStream(response.body!!.byteStream())
|
|
||||||
displayInternal(subscription, notification, bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun detailActivityIntent(subscription: Subscription): PendingIntent? {
|
private fun detailActivityIntent(subscription: Subscription): PendingIntent? {
|
||||||
val intent = Intent(context, DetailActivity::class.java)
|
val intent = Intent(context, DetailActivity::class.java)
|
||||||
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
|
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
package io.heckel.ntfy.ui
|
package io.heckel.ntfy.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -11,6 +13,7 @@ import android.view.*
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
@ -114,8 +117,44 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
||||||
// Background things
|
// Background things
|
||||||
startPeriodicPollWorker()
|
startPeriodicPollWorker()
|
||||||
startPeriodicServiceRestartWorker()
|
startPeriodicServiceRestartWorker()
|
||||||
}
|
|
||||||
|
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
|
||||||
|
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1234);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Toast.makeText(this, "Permission already granted", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onRequestPermissionsResult(requestCode: Int,
|
||||||
|
permissions: Array<String>,
|
||||||
|
grantResults: IntArray) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == 1234) { // FIXME
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Toast.makeText(this@MainActivity, "Camera Permission Granted", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this@MainActivity, "Camera Permission Denied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
public static final int REQUEST_WRITE_STORAGE = 112;
|
||||||
|
|
||||||
|
fun requestPermission(Activity context) {
|
||||||
|
boolean hasPermission = (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED);
|
||||||
|
if (!hasPermission) {
|
||||||
|
ActivityCompat.requestPermissions(context,
|
||||||
|
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
|
||||||
|
REQUEST_WRITE_STORAGE);
|
||||||
|
} else {
|
||||||
|
// You are allowed to write external storage:
|
||||||
|
String path = Environment.getExternalStorageDirectory().getAbsolutePath() + "/new_folder";
|
||||||
|
File storageDir = new File(path);
|
||||||
|
if (!storageDir.exists() && !storageDir.mkdirs()) {
|
||||||
|
// This should never happen - log handled exception!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
showHideNotificationMenuItems()
|
showHideNotificationMenuItems()
|
||||||
|
|
|
@ -33,4 +33,5 @@ ext {
|
||||||
coreKtxVersion = '1.3.2'
|
coreKtxVersion = '1.3.2'
|
||||||
constraintLayoutVersion = '2.0.4'
|
constraintLayoutVersion = '2.0.4'
|
||||||
activityVersion = '1.1.0'
|
activityVersion = '1.1.0'
|
||||||
|
fragmentVersion = '1.1.0'
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue