Work on app

This commit is contained in:
Oupson 2021-06-25 16:34:25 +02:00
parent 31c7529d45
commit 21f9512688
19 changed files with 323 additions and 250 deletions

View File

@ -1,6 +1,5 @@
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android { android {
@ -29,8 +28,7 @@ android {
} }
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.3.0' implementation 'androidx.appcompat:appcompat:1.3.0'

View File

@ -1,2 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="oupson.apng"> <manifest package="oupson.apng">
</manifest> </manifest>

View File

@ -71,6 +71,7 @@ class ApngDecoder(input: InputStream, val config: Config) {
val inputStream = BufferedInputStream(inputStream) val inputStream = BufferedInputStream(inputStream)
val bytes = ByteArray(8) val bytes = ByteArray(8)
inputStream.mark(8) inputStream.mark(8)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
inputStream.read(bytes) inputStream.read(bytes)
} }
@ -108,7 +109,6 @@ class ApngDecoder(input: InputStream, val config: Config) {
if (withContext(Dispatchers.IO) { if (withContext(Dispatchers.IO) {
byteRead = inputStream.read(lengthChunk) byteRead = inputStream.read(lengthChunk)
if (byteRead != -1) { if (byteRead != -1) {
length = Utils.uIntFromBytesBigEndian(lengthChunk) length = Utils.uIntFromBytesBigEndian(lengthChunk)
@ -149,6 +149,7 @@ class ApngDecoder(input: InputStream, val config: Config) {
) )
} }
} }
cover?.close()
cover = null cover = null
} else { } else {
// Add IEND body length : 0 // Add IEND body length : 0
@ -234,9 +235,9 @@ class ApngDecoder(input: InputStream, val config: Config) {
} }
else -> buffer = btm else -> buffer = btm
} }
} }
png?.close()
png = ByteArrayOutputStream(4096) png = ByteArrayOutputStream(4096)
// Parse Frame ConTroL chunk // Parse Frame ConTroL chunk
@ -318,6 +319,7 @@ class ApngDecoder(input: InputStream, val config: Config) {
) )
val pngBytes = png.toByteArray() val pngBytes = png.toByteArray()
png.close()
val decoded = BitmapFactory.decodeByteArray( val decoded = BitmapFactory.decodeByteArray(
pngBytes, pngBytes,
0, 0,
@ -393,11 +395,14 @@ class ApngDecoder(input: InputStream, val config: Config) {
crC32.update(Utils.IEND, 0, Utils.IEND.size) crC32.update(Utils.IEND, 0, Utils.IEND.size)
it.write(Utils.IEND) it.write(Utils.IEND)
it.write(Utils.uIntToByteArray(crC32.value.toInt())) it.write(Utils.uIntToByteArray(crC32.value.toInt()))
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
inputStream.close() inputStream.close()
} }
val pngBytes = it.toByteArray() val pngBytes = it.toByteArray()
it.close()
return@withContext BitmapDrawable( return@withContext BitmapDrawable(
context.resources, context.resources,
BitmapFactory.decodeByteArray( BitmapFactory.decodeByteArray(
@ -530,16 +535,14 @@ class ApngDecoder(input: InputStream, val config: Config) {
suspend fun getDecoded(context: Context): Result<Drawable> { suspend fun getDecoded(context: Context): Result<Drawable> {
if (result == null) { if (result == null) {
result = result = decodeApng(context)
decodeApng(context)
kotlin.runCatching { kotlin.runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
inputStream?.close() inputStream?.close()
} }
}.onFailure { }.onFailure {
return Result.failure(it) this.result = Result.failure(it)
} }
inputStream = null inputStream = null

View File

@ -10,9 +10,9 @@ import android.widget.ImageView
import androidx.annotation.RawRes import androidx.annotation.RawRes
import kotlinx.coroutines.* import kotlinx.coroutines.*
import oupson.apng.drawable.ApngDrawable import oupson.apng.drawable.ApngDrawable
import oupson.apng.utils.Utils.Companion.mapResult
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileNotFoundException
import java.net.URL import java.net.URL
class ApngLoader(parent: Job? = null) { class ApngLoader(parent: Job? = null) {
@ -50,18 +50,18 @@ class ApngLoader(parent: Job? = null) {
file: File, file: File,
imageView: ImageView, imageView: ImageView,
config: ApngDecoder.Config = ApngDecoder.Config() config: ApngDecoder.Config = ApngDecoder.Config()
): Result<Drawable> { ): Result<Drawable> =
val result = kotlin.runCatching {
withContext(Dispatchers.IO) {
FileInputStream(file)
}
}.mapResult { input ->
ApngDecoder( ApngDecoder(
withContext(Dispatchers.IO) { input,
FileInputStream(file)
},
config config
).getDecoded(context) ).getDecoded(context)
}.onSuccess { drawable ->
if (result.isSuccess) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val drawable = result.getOrNull()
imageView.setImageDrawable(drawable) imageView.setImageDrawable(drawable)
(drawable as? AnimationDrawable)?.start() (drawable as? AnimationDrawable)?.start()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@ -69,8 +69,6 @@ class ApngLoader(parent: Job? = null) {
} }
} }
} }
return result
}
/** /**
* Load Apng into an imageView. * Load Apng into an imageView.
@ -84,19 +82,16 @@ class ApngLoader(parent: Job? = null) {
uri: Uri, uri: Uri,
imageView: ImageView, imageView: ImageView,
config: ApngDecoder.Config = ApngDecoder.Config() config: ApngDecoder.Config = ApngDecoder.Config()
): Result<Drawable> { ): Result<Drawable> =
val inputStream = kotlin.runCatching {
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }!!
?: throw FileNotFoundException("Failed to load $uri") // TODO Result }.mapResult { inputStream ->
val result =
ApngDecoder( ApngDecoder(
inputStream, inputStream,
config config
).getDecoded(context) ).getDecoded(context)
}.onSuccess { drawable ->
if (result.isSuccess) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val drawable = result.getOrNull()
imageView.setImageDrawable(drawable) imageView.setImageDrawable(drawable)
(drawable as? AnimationDrawable)?.start() (drawable as? AnimationDrawable)?.start()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@ -104,8 +99,7 @@ class ApngLoader(parent: Job? = null) {
} }
} }
} }
return result
}
/** /**
* Load Apng into an imageView. * Load Apng into an imageView.
@ -118,27 +112,23 @@ class ApngLoader(parent: Job? = null) {
context: Context, @RawRes res: Int, context: Context, @RawRes res: Int,
imageView: ImageView, imageView: ImageView,
config: ApngDecoder.Config = ApngDecoder.Config() config: ApngDecoder.Config = ApngDecoder.Config()
): Result<Drawable> { ): Result<Drawable> =
val result = ApngDecoder(
ApngDecoder( withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { context.resources.openRawResource(res)
context.resources.openRawResource(res) },
}, config
config ).getDecoded(context)
).getDecoded(context) .onSuccess { drawable ->
withContext(Dispatchers.Main) {
if (result.isSuccess) { imageView.setImageDrawable(drawable)
withContext(Dispatchers.Main) { (drawable as? AnimationDrawable)?.start()
val drawable = result.getOrNull() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
imageView.setImageDrawable(drawable) (drawable as? AnimatedImageDrawable)?.start()
(drawable as? AnimationDrawable)?.start() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
(drawable as? AnimatedImageDrawable)?.start()
} }
} }
}
return result
}
/** /**
* Load Apng into an imageView, asynchronously. * Load Apng into an imageView, asynchronously.
@ -152,23 +142,18 @@ class ApngLoader(parent: Job? = null) {
url: URL, url: URL,
imageView: ImageView, imageView: ImageView,
config: ApngDecoder.Config = ApngDecoder.Config() config: ApngDecoder.Config = ApngDecoder.Config()
): Result<Drawable> { ): Result<Drawable> =
val result = ApngDecoder.constructFromUrl(url, config).getOrElse { return Result.failure(it) }
ApngDecoder.constructFromUrl(url, config).getOrElse { return Result.failure(it) } .getDecoded(context)
.getDecoded(context) .onSuccess { drawable ->
if (result.isSuccess) { withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { imageView.setImageDrawable(drawable)
val drawable = result.getOrNull() (drawable as? AnimationDrawable)?.start()
imageView.setImageDrawable(drawable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
(drawable as? AnimationDrawable)?.start() (drawable as? AnimatedImageDrawable)?.start()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { }
(drawable as? AnimatedImageDrawable)?.start()
} }
} }
}
return result
}
/** /**
* Load Apng into an imageView, asynchronously. * Load Apng into an imageView, asynchronously.
@ -185,12 +170,16 @@ class ApngLoader(parent: Job? = null) {
config: ApngDecoder.Config = ApngDecoder.Config() config: ApngDecoder.Config = ApngDecoder.Config()
): Result<Drawable> { ): Result<Drawable> {
return if (string.startsWith("http://") || string.startsWith("https://")) { return if (string.startsWith("http://") || string.startsWith("https://")) {
decodeApngInto( kotlin.runCatching { URL(string) }
context, .mapResult { url ->
URL(string), decodeApngInto(
imageView, context,
config url,
) imageView,
config
)
}
} else if (File(string).exists()) { } else if (File(string).exists()) {
var pathToLoad = var pathToLoad =
if (string.startsWith("content://")) string else "file://$string" if (string.startsWith("content://")) string else "file://$string"
@ -202,25 +191,21 @@ class ApngLoader(parent: Job? = null) {
config config
) )
} else if (string.startsWith("file://android_asset/")) { } else if (string.startsWith("file://android_asset/")) {
val inputStream = kotlin.runCatching { kotlin.runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
context.assets.open(string.replace("file:///android_asset/", "")) context.assets.open(string.replace("file:///android_asset/", ""))
} }
}.getOrElse {
return Result.failure(it)
} }
val result = ApngDecoder(inputStream, config).getDecoded(context) .mapResult { inputStream -> ApngDecoder(inputStream, config).getDecoded(context) }
if (result.isSuccess) { .onSuccess { drawable ->
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val drawable = result.getOrNull() imageView.setImageDrawable(drawable)
imageView.setImageDrawable(drawable) (drawable as? AnimationDrawable)?.start()
(drawable as? AnimationDrawable)?.start() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { (drawable as? AnimatedImageDrawable)?.start()
(drawable as? AnimatedImageDrawable)?.start() }
} }
} }
}
result
} else { } else {
throw Exception("Cannot open string") throw Exception("Cannot open string")
} }

View File

@ -291,7 +291,7 @@ class ApngEncoder(
) { ) {
if (currentFrame == 0) { if (currentFrame == 0) {
if (btm.width != width || btm.height != height) if (btm.width != width || btm.height != height)
throw InvalidFrameSizeException( throw InvalidFrameSizeException( // TODO
btm.width, btm.width,
btm.height, btm.height,
width, width,

View File

@ -24,8 +24,11 @@ class Loader {
val connection = url.openConnection() as HttpURLConnection val connection = url.openConnection() as HttpURLConnection
connection.useCaches = true connection.useCaches = true
connection.connect() connection.connect()
val inputStream = connection.inputStream
if (connection.responseCode == 200) { if (connection.responseCode == 200) {
val input = BufferedInputStream(connection.inputStream) val input = BufferedInputStream(inputStream)
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
var bytesRead: Int var bytesRead: Int
val buffer = ByteArray(4096) val buffer = ByteArray(4096)
@ -36,9 +39,13 @@ class Loader {
} while (bytesRead != -1) } while (bytesRead != -1)
input.close() input.close()
output.close() output.close()
inputStream.close()
connection.disconnect() connection.disconnect()
output.toByteArray() output.toByteArray()
} else { } else {
inputStream.close()
connection.disconnect() connection.disconnect()
throw Exception("Error when downloading file : ${connection.responseCode}") throw Exception("Error when downloading file : ${connection.responseCode}")
} }

View File

@ -208,7 +208,7 @@ class Utils {
(bytes[1] and 0xFF)) (bytes[1] and 0xFF))
// TODO DOCUMENT AND TEST // TODO DOCUMENT AND TEST
fun uShortFromBytesBigEndian(bytes: ByteArray, offset : Int = 0): Int = fun uShortFromBytesBigEndian(bytes: ByteArray, offset: Int = 0): Int =
(((bytes[offset].toInt() and 0xFF) shl 8) or (((bytes[offset].toInt() and 0xFF) shl 8) or
(bytes[offset + 1].toInt() and 0xFF)) (bytes[offset + 1].toInt() and 0xFF))
@ -330,5 +330,14 @@ class Utils {
} }
return result return result
} }
suspend fun <T, U> Result<T>.mapResult(block: suspend (T) -> Result<U>): Result<U> {
return this.fold(
onSuccess = {
block.invoke(it)
},
onFailure = { Result.failure(it) }
)
}
} }
} }

View File

@ -1,6 +1,5 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android { android {
compileSdkVersion 30 compileSdkVersion 30
@ -24,29 +23,35 @@ android {
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }
productFlavors { productFlavors {
} }
buildFeatures {
viewBinding = true
}
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3'
implementation 'androidx.appcompat:appcompat:1.3.0' implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.google.android.material:material:1.3.0' implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
implementation 'com.squareup.picasso:picasso:2.71828' implementation("io.coil-kt:coil:1.2.2")
implementation project(":apng_library") implementation project(":apng_library")
// implementation fileTree(include: ['*.aar'], dir: 'libs') // implementation fileTree(include: ['*.aar'], dir: 'libs')

View File

@ -14,18 +14,18 @@ import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_creator.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import oupson.apng.encoder.ApngEncoder import oupson.apng.encoder.ApngEncoder
@ -33,6 +33,7 @@ import oupson.apng.utils.Utils
import oupson.apngcreator.BuildConfig import oupson.apngcreator.BuildConfig
import oupson.apngcreator.R import oupson.apngcreator.R
import oupson.apngcreator.adapter.ImageAdapter import oupson.apngcreator.adapter.ImageAdapter
import oupson.apngcreator.databinding.ActivityCreatorBinding
import oupson.apngcreator.dialogs.DelayInputDialog import oupson.apngcreator.dialogs.DelayInputDialog
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@ -42,8 +43,6 @@ import kotlin.collections.ArrayList
class CreatorActivity : AppCompatActivity() { class CreatorActivity : AppCompatActivity() {
companion object { companion object {
private const val PICK_IMAGE = 1
private const val WRITE_REQUEST_CODE = 2
private const val TAG = "CreatorActivity" private const val TAG = "CreatorActivity"
private const val CREATION_CHANNEL_ID = private const val CREATION_CHANNEL_ID =
@ -57,35 +56,97 @@ class CreatorActivity : AppCompatActivity() {
private var nextImageId: Long = 0 private var nextImageId: Long = 0
private var binding: ActivityCreatorBinding? = null
private val pickLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data: Intent? = result.data
if (data?.clipData != null) {
for (i in 0 until data.clipData!!.itemCount) {
items.add(Triple(data.clipData!!.getItemAt(i).uri, 1000, nextImageId++))
}
adapter?.notifyDataSetChanged()
} else if (data?.data != null) {
items.add(Triple(data.data!!, 1000, nextImageId++))
adapter?.notifyDataSetChanged()
}
}
}
private val writeLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data: Intent? = result.data
if (data?.data != null) {
if (BuildConfig.DEBUG)
Log.i(TAG, "Intent data : ${data.data}")
val builder = NotificationCompat.Builder(this, CREATION_CHANNEL_ID).apply {
setContentTitle(getString(R.string.create_notification_title))
setContentText(
this@CreatorActivity.resources.getQuantityString(
R.plurals.create_notification_description,
0,
0,
items.size
)
)
setSmallIcon(R.drawable.ic_create_white_24dp)
priority = NotificationCompat.PRIORITY_LOW
}
lifecycleScope.launch(Dispatchers.IO) {
val out = contentResolver.openOutputStream(data.data!!) ?: return@launch
saveToOutputStream(
items.map { Pair(it.first, it.second) },
out,
builder = builder
)
out.close()
if (binding != null) {
withContext(Dispatchers.Main) {
Snackbar.make(
binding!!.imageRecyclerView,
R.string.done,
Snackbar.LENGTH_SHORT
).show()
}
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityCreatorBinding.inflate(layoutInflater)
setContentView(R.layout.activity_creator) setContentView(binding?.root)
fabAddImage.setOnClickListener { binding?.fabAddImage?.setOnClickListener {
val getIntent = Intent(Intent.ACTION_GET_CONTENT) val getIntent = Intent(Intent.ACTION_GET_CONTENT)
getIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) getIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
getIntent.type = "image/*" getIntent.type = "image/*"
startActivityForResult( pickLauncher.launch(getIntent)
getIntent,
PICK_IMAGE
)
} }
adapter = ImageAdapter(this, items) adapter = ImageAdapter(this, items, lifecycleScope)
adapter?.setHasStableIds(true) adapter?.setHasStableIds(true)
imageRecyclerView.layoutManager = LinearLayoutManager(this) binding?.imageRecyclerView?.layoutManager = LinearLayoutManager(this)
imageRecyclerView.setHasFixedSize(true) binding?.imageRecyclerView?.setHasFixedSize(true)
imageRecyclerView.itemAnimator = object : DefaultItemAnimator() { binding?.imageRecyclerView?.itemAnimator = object : DefaultItemAnimator() {
override fun canReuseUpdatedViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean { override fun canReuseUpdatedViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean {
return true return true
} }
} }
imageRecyclerView.setItemViewCacheSize(20) binding?.imageRecyclerView?.setItemViewCacheSize(20)
if (adapter != null) if (adapter != null)
ItemTouchHelper(SwipeToDeleteCallback(adapter!!)).attachToRecyclerView(imageRecyclerView) ItemTouchHelper(SwipeToDeleteCallback(adapter!!)).attachToRecyclerView(binding?.imageRecyclerView)
adapter?.clickListener = { position -> adapter?.clickListener = { position ->
DelayInputDialog(object : DelayInputDialog.InputSenderDialogListener { DelayInputDialog(object : DelayInputDialog.InputSenderDialogListener {
@ -104,8 +165,8 @@ class CreatorActivity : AppCompatActivity() {
}, items[position].second).show(supportFragmentManager, null) }, items[position].second).show(supportFragmentManager, null)
} }
setSupportActionBar(creatorBottomAppBar) setSupportActionBar(binding?.creatorBottomAppBar)
imageRecyclerView.adapter = adapter binding?.imageRecyclerView?.adapter = adapter
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -148,7 +209,7 @@ class CreatorActivity : AppCompatActivity() {
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
GlobalScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val randomFileName = UUID.randomUUID().toString() val randomFileName = UUID.randomUUID().toString()
val f = File(filesDir, "images/$randomFileName.png").apply { val f = File(filesDir, "images/$randomFileName.png").apply {
if (!exists()) { if (!exists()) {
@ -205,7 +266,7 @@ class CreatorActivity : AppCompatActivity() {
setSmallIcon(R.drawable.ic_create_white_24dp) setSmallIcon(R.drawable.ic_create_white_24dp)
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
GlobalScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val randomFileName = UUID.randomUUID().toString() val randomFileName = UUID.randomUUID().toString()
val f = File(filesDir, "images/$randomFileName.png").apply { val f = File(filesDir, "images/$randomFileName.png").apply {
if (!exists()) { if (!exists()) {
@ -272,7 +333,10 @@ class CreatorActivity : AppCompatActivity() {
type = "image/png" type = "image/png"
putExtra(Intent.EXTRA_TITLE, "${items[0].first.lastPathSegment}.png") putExtra(Intent.EXTRA_TITLE, "${items[0].first.lastPathSegment}.png")
} }
startActivityForResult(intent, WRITE_REQUEST_CODE)
writeLauncher.launch(
intent
)
} }
true true
} }
@ -409,7 +473,9 @@ class CreatorActivity : AppCompatActivity() {
} }
} }
encoder.writeEnd() withContext(Dispatchers.IO) {
encoder.writeEnd()
}
if (builder != null) { if (builder != null) {
@ -423,71 +489,14 @@ class CreatorActivity : AppCompatActivity() {
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
PICK_IMAGE -> {
if (resultCode == Activity.RESULT_OK) {
if (data?.clipData != null) {
for (i in 0 until data.clipData!!.itemCount) {
items.add(Triple(data.clipData!!.getItemAt(i).uri, 1000, nextImageId++))
}
adapter?.notifyDataSetChanged()
} else if (data?.data != null) {
items.add(Triple(data.data!!, 1000, nextImageId++))
adapter?.notifyDataSetChanged()
}
}
}
WRITE_REQUEST_CODE -> {
if (resultCode == Activity.RESULT_OK) {
if (data?.data != null) {
if (BuildConfig.DEBUG)
Log.i(TAG, "Intent data : ${data.data}")
val builder = NotificationCompat.Builder(this, CREATION_CHANNEL_ID).apply {
setContentTitle(getString(R.string.create_notification_title))
setContentText(
this@CreatorActivity.resources.getQuantityString(
R.plurals.create_notification_description,
0,
0,
items.size
)
)
setSmallIcon(R.drawable.ic_create_white_24dp)
priority = NotificationCompat.PRIORITY_LOW
}
GlobalScope.launch(Dispatchers.IO) {
val out = contentResolver.openOutputStream(data.data!!) ?: return@launch
saveToOutputStream(
items.map { Pair(it.first, it.second) },
out,
builder = builder
)
out.close()
withContext(Dispatchers.Main) {
Snackbar.make(
imageRecyclerView,
R.string.done,
Snackbar.LENGTH_SHORT
).show()
}
}
}
}
}
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
val deleteResult = File(filesDir, "images").deleteRecursively() lifecycleScope.launch(Dispatchers.IO) {
if (BuildConfig.DEBUG) val deleteResult = File(filesDir, "images").deleteRecursively()
Log.v(TAG, "Deleted images dir : $deleteResult") if (BuildConfig.DEBUG)
Log.v(TAG, "Deleted images dir : $deleteResult")
}
} }
inner class SwipeToDeleteCallback(private val adapter: ImageAdapter) : inner class SwipeToDeleteCallback(private val adapter: ImageAdapter) :

View File

@ -4,19 +4,25 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.net.http.HttpResponseCache import android.net.http.HttpResponseCache
import android.os.Bundle import android.os.Bundle
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log import android.util.Log
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomappbar.BottomAppBarTopEdgeTreatment import com.google.android.material.bottomappbar.BottomAppBarTopEdgeTreatment
import com.google.android.material.shape.CutCornerTreatment import com.google.android.material.shape.CutCornerTreatment
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.shape.ShapePath import com.google.android.material.shape.ShapePath
import kotlinx.android.synthetic.main.activity_main.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import oupson.apngcreator.BuildConfig import oupson.apngcreator.BuildConfig
import oupson.apngcreator.R import oupson.apngcreator.R
import oupson.apngcreator.databinding.ActivityMainBinding
import oupson.apngcreator.fragments.ApngDecoderFragment import oupson.apngcreator.fragments.ApngDecoderFragment
import oupson.apngcreator.fragments.JavaFragment import oupson.apngcreator.fragments.JavaFragment
import oupson.apngcreator.fragments.KotlinFragment import oupson.apngcreator.fragments.KotlinFragment
@ -28,38 +34,67 @@ class MainActivity : AppCompatActivity() {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
} }
private var binding: ActivityMainBinding? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectAll()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.penaltyLog()
.build()
)
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (BuildConfig.DEBUG) if (BuildConfig.DEBUG)
Log.v(TAG, "supportFragmentManager.fragments.size : ${supportFragmentManager.fragments.size}") Log.v(
TAG,
"supportFragmentManager.fragments.size : ${supportFragmentManager.fragments.size}"
)
setContentView(R.layout.activity_main) binding = ActivityMainBinding.inflate(layoutInflater)
setSupportActionBar(bottomAppBar)
setContentView(binding?.root)
setSupportActionBar(binding?.bottomAppBar)
setUpBottomAppBarShapeAppearance() setUpBottomAppBarShapeAppearance()
val httpCacheSize = 10 * 1024 * 1024.toLong() // 10 MiB val httpCacheSize = 10 * 1024 * 1024.toLong() // 10 MiB
val httpCacheDir = File(cacheDir, "http") lifecycleScope.launch(Dispatchers.IO) {
HttpResponseCache.install(httpCacheDir, httpCacheSize) val httpCacheDir = File(cacheDir, "http")
HttpResponseCache.install(httpCacheDir, httpCacheSize)
}
fabCreate.setOnClickListener { binding?.fabCreate?.setOnClickListener {
startActivity(Intent(this, CreatorActivity::class.java)) startActivity(Intent(this, CreatorActivity::class.java))
} }
val drawerToggle = ActionBarDrawerToggle(this, drawer_layout, bottomAppBar, val drawerToggle = ActionBarDrawerToggle(
this, binding?.drawerLayout, binding?.bottomAppBar,
R.string.open, R.string.open,
R.string.close R.string.close
) )
drawer_layout.addDrawerListener(drawerToggle) binding?.drawerLayout?.addDrawerListener(drawerToggle)
drawerToggle.syncState() drawerToggle.syncState()
var selected = 0 var selected = 0
navigationView.setNavigationItemSelectedListener { menuItem : MenuItem -> binding?.navigationView?.setNavigationItemSelectedListener { menuItem: MenuItem ->
when(menuItem.itemId) { when (menuItem.itemId) {
R.id.menu_kotlin_fragment -> { R.id.menu_kotlin_fragment -> {
if (selected != 0) { if (selected != 0) {
supportFragmentManager.beginTransaction().apply { supportFragmentManager.beginTransaction().apply {
@ -98,20 +133,21 @@ class MainActivity : AppCompatActivity() {
} }
} }
drawer_layout.closeDrawer(GravityCompat.START) binding?.drawerLayout?.closeDrawer(GravityCompat.START)
return@setNavigationItemSelectedListener true return@setNavigationItemSelectedListener true
} }
if (intent.hasExtra("fragment") && supportFragmentManager.fragments.size == 0) { if (intent.hasExtra("fragment") && supportFragmentManager.fragments.size == 0) {
when(intent.getStringExtra("fragment")) { when (intent.getStringExtra("fragment")) {
"kotlin" -> { "kotlin" -> {
supportFragmentManager.beginTransaction().apply { supportFragmentManager.beginTransaction().apply {
add( add(
R.id.fragment_container, R.id.fragment_container,
KotlinFragment.newInstance(), "KotlinFragment") KotlinFragment.newInstance(), "KotlinFragment"
)
}.commit() }.commit()
navigationView.setCheckedItem(R.id.menu_kotlin_fragment) binding?.navigationView?.setCheckedItem(R.id.menu_kotlin_fragment)
selected = 0 selected = 0
} }
"java" -> { "java" -> {
@ -121,7 +157,7 @@ class MainActivity : AppCompatActivity() {
JavaFragment() JavaFragment()
) )
}.commit() }.commit()
navigationView.setCheckedItem(R.id.menu_java_fragment) binding?.navigationView?.setCheckedItem(R.id.menu_java_fragment)
selected = 1 selected = 1
} }
"apng_decoder" -> { "apng_decoder" -> {
@ -131,7 +167,7 @@ class MainActivity : AppCompatActivity() {
ApngDecoderFragment.newInstance() ApngDecoderFragment.newInstance()
) )
}.commit() }.commit()
navigationView.setCheckedItem(R.id.menu_apng_decoder_fragment) binding?.navigationView?.setCheckedItem(R.id.menu_apng_decoder_fragment)
selected = 2 selected = 2
} }
} }
@ -139,35 +175,41 @@ class MainActivity : AppCompatActivity() {
supportFragmentManager.beginTransaction().apply { supportFragmentManager.beginTransaction().apply {
add( add(
R.id.fragment_container, R.id.fragment_container,
KotlinFragment.newInstance(), "KotlinFragment") KotlinFragment.newInstance(), "KotlinFragment"
)
}.commit() }.commit()
navigationView.setCheckedItem(R.id.menu_kotlin_fragment) binding?.navigationView?.setCheckedItem(R.id.menu_kotlin_fragment)
} }
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
HttpResponseCache.getInstalled()?.flush() lifecycleScope.launch(Dispatchers.IO) {
HttpResponseCache.getInstalled()?.flush()
}
} }
private fun setUpBottomAppBarShapeAppearance() { private fun setUpBottomAppBarShapeAppearance() {
val fabShapeAppearanceModel: ShapeAppearanceModel = fabCreate.shapeAppearanceModel if (binding != null) {
val cutCornersFab = val fabShapeAppearanceModel: ShapeAppearanceModel =
(fabShapeAppearanceModel.bottomLeftCorner is CutCornerTreatment binding!!.fabCreate.shapeAppearanceModel
&& fabShapeAppearanceModel.bottomRightCorner is CutCornerTreatment) val cutCornersFab =
val topEdge = (fabShapeAppearanceModel.bottomLeftCorner is CutCornerTreatment
if (cutCornersFab) BottomAppBarCutCornersTopEdge( && fabShapeAppearanceModel.bottomRightCorner is CutCornerTreatment)
bottomAppBar.fabCradleMargin, val topEdge =
bottomAppBar.fabCradleRoundedCornerRadius, if (cutCornersFab) BottomAppBarCutCornersTopEdge(
bottomAppBar.cradleVerticalOffset binding!!.bottomAppBar.fabCradleMargin,
) else BottomAppBarTopEdgeTreatment( binding!!.bottomAppBar.fabCradleRoundedCornerRadius,
bottomAppBar.fabCradleMargin, binding!!.bottomAppBar.cradleVerticalOffset
bottomAppBar.fabCradleRoundedCornerRadius, ) else BottomAppBarTopEdgeTreatment(
bottomAppBar.cradleVerticalOffset binding!!.bottomAppBar.fabCradleMargin,
) binding!!.bottomAppBar.fabCradleRoundedCornerRadius,
val babBackground = bottomAppBar.background as MaterialShapeDrawable binding!!.bottomAppBar.cradleVerticalOffset
babBackground.shapeAppearanceModel = )
babBackground.shapeAppearanceModel.toBuilder().setTopEdge(topEdge).build() val babBackground = binding!!.bottomAppBar.background as MaterialShapeDrawable
babBackground.shapeAppearanceModel =
babBackground.shapeAppearanceModel.toBuilder().setTopEdge(topEdge).build()
}
} }

View File

@ -9,16 +9,19 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.android.synthetic.main.activity_viewer.*
import oupson.apng.decoder.ApngDecoder import oupson.apng.decoder.ApngDecoder
import oupson.apng.decoder.ApngLoader import oupson.apng.decoder.ApngLoader
import oupson.apngcreator.R import oupson.apngcreator.databinding.ActivityViewerBinding
class ViewerActivity : AppCompatActivity() { class ViewerActivity : AppCompatActivity() {
private var apngLoader: ApngLoader? = null private var apngLoader: ApngLoader? = null
private var binding: ActivityViewerBinding? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_viewer) binding = ActivityViewerBinding.inflate(layoutInflater)
setContentView(binding?.root)
this.apngLoader = ApngLoader() this.apngLoader = ApngLoader()
@ -54,18 +57,20 @@ class ViewerActivity : AppCompatActivity() {
private fun load() { private fun load() {
val uri = intent.data ?: return val uri = intent.data ?: return
apngLoader?.decodeApngAsyncInto(
this, if (binding != null)
uri, apngLoader?.decodeApngAsyncInto(
viewerImageView, this,
callback = object : ApngLoader.Callback { uri,
override fun onSuccess(drawable: Drawable) {} binding!!.viewerImageView,
override fun onError(error: Throwable) { callback = object : ApngLoader.Callback {
Log.e("ViewerActivity", "Error when loading file", error) override fun onSuccess(drawable: Drawable) {}
} override fun onError(error: Throwable) {
}, Log.e("ViewerActivity", "Error when loading file", error)
ApngDecoder.Config(decodeCoverFrame = false) }
) },
ApngDecoder.Config(decodeCoverFrame = false)
)
} }
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(

View File

@ -10,21 +10,25 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import oupson.apngcreator.R import oupson.apngcreator.R
class ImageAdapter(private val context : Context, private val list : List<Triple<Uri, Int, Long>>) : RecyclerView.Adapter<ImageAdapter.ImageHolder>() { class ImageAdapter(
inner class ImageHolder(view : View) : RecyclerView.ViewHolder(view) { private val context: Context,
val imageView : ImageView? = view.findViewById(R.id.listImageView) private val list: List<Triple<Uri, Int, Long>>,
val textDelay : TextView? = view.findViewById(R.id.textDelay) private val scope: CoroutineScope
val positionTextView : TextView? = view.findViewById(R.id.position_textView) ) : RecyclerView.Adapter<ImageAdapter.ImageHolder>() {
val nameTextView : TextView? = view.findViewById(R.id.name_textView) inner class ImageHolder(view: View) : RecyclerView.ViewHolder(view) {
val imageView: ImageView? = view.findViewById(R.id.listImageView)
val textDelay: TextView? = view.findViewById(R.id.textDelay)
val positionTextView: TextView? = view.findViewById(R.id.position_textView)
val nameTextView: TextView? = view.findViewById(R.id.name_textView)
} }
var clickListener : ((position : Int) -> Unit)? = null var clickListener: ((position: Int) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
@ -36,7 +40,7 @@ class ImageAdapter(private val context : Context, private val list : List<Triple
holder.textDelay?.text = String.format("%dms", list[position].second) holder.textDelay?.text = String.format("%dms", list[position].second)
holder.positionTextView?.text = String.format("# %03d", holder.adapterPosition + 1) holder.positionTextView?.text = String.format("# %03d", holder.adapterPosition + 1)
holder.nameTextView?.text = list[position].first.path?.substringAfterLast("/") holder.nameTextView?.text = list[position].first.path?.substringAfterLast("/")
GlobalScope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(list[position].first) val inputStream = context.contentResolver.openInputStream(list[position].first)
val btm = val btm =
BitmapFactory.decodeStream(inputStream, null, BitmapFactory.Options().apply { BitmapFactory.decodeStream(inputStream, null, BitmapFactory.Options().apply {

View File

@ -11,8 +11,7 @@ import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.SeekBar import android.widget.SeekBar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.squareup.picasso.Picasso import coil.load
import kotlinx.android.synthetic.main.activity_creator.*
import oupson.apng.decoder.ApngLoader import oupson.apng.decoder.ApngLoader
import oupson.apng.drawable.ApngDrawable import oupson.apng.drawable.ApngDrawable
import oupson.apngcreator.BuildConfig import oupson.apngcreator.BuildConfig
@ -145,7 +144,7 @@ class KotlinFragment : Fragment() {
} }
}) })
if (animation == null) { if ((animation == null)) {
apngLoader?.decodeApngAsyncInto( apngLoader?.decodeApngAsyncInto(
requireContext(), requireContext(),
imageUrls[selected], imageUrls[selected],
@ -158,13 +157,13 @@ class KotlinFragment : Fragment() {
} }
} }
override fun onError(error: Exception) { override fun onError(error: Throwable) {
Log.e(TAG, "Error when decoding apng", error) Log.e(TAG, "Error when decoding apng", error)
} }
}) })
} }
Picasso.get().load(imageUrls[selected]).into(normalImageView) normalImageView?.load(imageUrls[selected])
} }
override fun onPause() { override fun onPause() {

View File

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_height="match_parent"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/delay_textInputLayout" android:id="@+id/delay_textInputLayout"

View File

@ -3,7 +3,8 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".fragments.ApngDecoderFragment"> tools:context=".fragments.ApngDecoderFragment"
tools:viewBindingIgnore="true">
<androidx.appcompat.widget.AppCompatImageView <androidx.appcompat.widget.AppCompatImageView
android:id="@+id/apngDecoderImageView" android:id="@+id/apngDecoderImageView"

View File

@ -4,7 +4,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".fragments.JavaFragment"> tools:context=".fragments.JavaFragment"
tools:viewBindingIgnore="true">
<androidx.appcompat.widget.AppCompatImageView <androidx.appcompat.widget.AppCompatImageView
android:id="@+id/javaImageView" android:id="@+id/javaImageView"

View File

@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -4,7 +4,8 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal"
tools:viewBindingIgnore="true">
<androidx.appcompat.widget.AppCompatTextView <androidx.appcompat.widget.AppCompatTextView
android:id="@+id/position_textView" android:id="@+id/position_textView"

View File

@ -1,8 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.5.10' ext.kotlin_version = '1.5.20'
ext.dokka_version = '1.4.3'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()