Shows Image

This commit is contained in:
tom5079
2021-12-01 17:18:19 +09:00
parent 70452ba7a6
commit 6c13a624a9
10 changed files with 326 additions and 41 deletions

View File

@@ -132,7 +132,7 @@ dependencies {
implementation("xyz.quaver:libpupil:2.1.11") implementation("xyz.quaver:libpupil:2.1.11")
implementation("xyz.quaver:documentfilex:0.7.1") implementation("xyz.quaver:documentfilex:0.7.1")
implementation("xyz.quaver:floatingsearchview:1.1.7") implementation("xyz.quaver:floatingsearchview:1.1.7")
implementation("xyz.quaver:subsampledimage:0.0.1-alpha01-SNAPSHOT") implementation("xyz.quaver:subsampledimage:0.0.1-alpha09-SNAPSHOT")
implementation("org.kodein.log:kodein-log:0.11.1") implementation("org.kodein.log:kodein-log:0.11.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.7") debugImplementation("com.squareup.leakcanary:leakcanary-android:2.7")

View File

@@ -58,6 +58,7 @@ class Pupil : Application(), DIAware {
import(sourceModule) import(sourceModule)
bind { singleton { DownloadManager(applicationContext) } } bind { singleton { DownloadManager(applicationContext) } }
bind { singleton { NetworkCache(applicationContext) } }
bind { singleton { bind { singleton {
HttpClient(OkHttp) { HttpClient(OkHttp) {

View File

@@ -21,6 +21,7 @@ package xyz.quaver.pupil.sources
import android.app.Application import android.app.Application
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.ktor.http.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.kodein.di.* import org.kodein.di.*
@@ -61,7 +62,7 @@ abstract class Source {
@Composable @Composable
open fun SearchResult(itemInfo: ItemInfo, onEvent: ((SearchResultEvent) -> Unit)? = null) { } open fun SearchResult(itemInfo: ItemInfo, onEvent: ((SearchResultEvent) -> Unit)? = null) { }
open fun getHeadersForImage(itemID: String, url: String): Map<String, String> = emptyMap() open fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = { }
open fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) { open fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) {
binding.leftIcon.setImageResource(R.drawable.tag) binding.leftIcon.setImageResource(R.drawable.tag)

View File

@@ -190,7 +190,7 @@ class Hitomi(app: Application) : Source(), DIAware {
val reader = getGalleryInfo(galleryID) val reader = getGalleryInfo(galleryID)
return reader.files.map { return reader.files.map {
imageUrlFromImage(galleryID, it, true) imageUrlFromImage(galleryID, it, false)
} }
} }
@@ -225,9 +225,9 @@ class Hitomi(app: Application) : Source(), DIAware {
FullSearchResult(itemInfo = itemInfo) FullSearchResult(itemInfo = itemInfo)
} }
override fun getHeadersForImage(itemID: String, url: String) = mapOf( override fun getHeadersBuilderForImage(itemID: String, url: String): HeadersBuilder.() -> Unit = {
"Referer" to getReferer(itemID.toInt()) append("Referer", getReferer(itemID.toInt()))
) }
override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) { override fun onSuggestionBind(binding: SearchSuggestionItemBinding, item: SearchSuggestion) {
item as TagSuggestion item as TagSuggestion

View File

@@ -24,40 +24,44 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.runtime.getValue import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import coil.request.ImageRequest
import coil.transform.BlurTransformation
import com.google.accompanist.appcompattheme.AppCompatTheme import com.google.accompanist.appcompattheme.AppCompatTheme
import io.ktor.http.* import kotlinx.coroutines.CoroutineScope
import okhttp3.Headers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.closestDI import org.kodein.di.android.closestDI
import org.kodein.log.LoggerFactory import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger import org.kodein.log.newLogger
import xyz.quaver.graphics.subsampledimage.SubSampledImage import xyz.quaver.graphics.subsampledimage.*
import xyz.quaver.io.FileX
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.composable.FloatingActionButtonState import xyz.quaver.pupil.ui.composable.FloatingActionButtonState
import xyz.quaver.pupil.ui.composable.MultipleFloatingActionButton import xyz.quaver.pupil.ui.composable.MultipleFloatingActionButton
import xyz.quaver.pupil.ui.composable.SubFabItem import xyz.quaver.pupil.ui.composable.SubFabItem
import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel import xyz.quaver.pupil.ui.viewmodel.ReaderViewModel
import xyz.quaver.pupil.util.FileXImageSource
import kotlin.math.abs
class ReaderActivity : ComponentActivity(), DIAware { class ReaderActivity : ComponentActivity(), DIAware {
override val di by closestDI() override val di by closestDI()
@@ -78,10 +82,31 @@ class ReaderActivity : ComponentActivity(), DIAware {
val isFullscreen by model.isFullscreen.observeAsState(false) val isFullscreen by model.isFullscreen.observeAsState(false)
val title by model.title.observeAsState(stringResource(R.string.reader_loading)) val title by model.title.observeAsState(stringResource(R.string.reader_loading))
val sourceIcon by model.sourceIcon.observeAsState() val sourceIcon by model.sourceIcon.observeAsState()
val images by model.images.observeAsState(emptyList()) val imageSources = remember { mutableStateListOf<ImageSource?>() }
val source by model.sourceInstance.observeAsState() val imageHeights = remember { mutableStateListOf<Float?>() }
val states = remember { mutableStateListOf<SubSampledImageState>() }
logger.debug { "target: ${R.drawable.hitomi} value: $sourceIcon" } LaunchedEffect(model.totalProgress) {
if (imageSources.isEmpty() && model.imageList.isNotEmpty())
imageSources.addAll(List(model.imageList.size) { null })
if (states.isEmpty() && model.imageList.isNotEmpty())
states.addAll(List(model.imageList.size) { SubSampledImageState(ScaleTypes.FIT_WIDTH, Bounds.FORCE_OVERLAP_OR_CENTER) })
if (imageHeights.isEmpty() && model.imageList.isNotEmpty())
imageHeights.addAll(List(model.imageList.size) { null })
model.imageList.forEachIndexed { i, image ->
if (imageSources[i] == null && image != null)
CoroutineScope(Dispatchers.Default).launch {
imageSources[i] = kotlin.runCatching {
FileXImageSource(FileX(this@ReaderActivity, image))
}.onFailure {
model.error(i)
}.getOrNull()
}
}
}
WindowInsetsControllerCompat(window, window.decorView).run { WindowInsetsControllerCompat(window, window.decorView).run {
if (isFullscreen) { if (isFullscreen) {
@@ -121,7 +146,7 @@ class ReaderActivity : ComponentActivity(), DIAware {
icon = Icons.Default.Fullscreen, icon = Icons.Default.Fullscreen,
label = stringResource(id = R.string.reader_fab_fullscreen) label = stringResource(id = R.string.reader_fab_fullscreen)
) { ) {
model.isFullscreen.postValue(!isFullscreen) model.isFullscreen.postValue(true)
} }
), ),
targetState = isFABExpanded, targetState = isFABExpanded,
@@ -132,14 +157,57 @@ class ReaderActivity : ComponentActivity(), DIAware {
} }
) { ) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(32.dp) Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(images) { image -> itemsIndexed(imageSources) { i, imageSource ->
SubSampledImage( LaunchedEffect(states[i].canvasSize, states[i].imageSize) {
modifier = Modifier.fillMaxWidth().heightIn(128.dp, 1000.dp) if (imageHeights.isNotEmpty() && imageHeights[i] == null)
) states[i].canvasSize?.let { canvasSize ->
states[i].imageSize?.let { imageSize ->
imageHeights[i] = imageSize.height * canvasSize.width / imageSize.width
} }
}
Box(
Modifier
.height(
imageHeights
.getOrNull(i)
?.let { with(LocalDensity.current) { it.toDp() } }
?: 500.dp)
.fillMaxWidth()
.border(1.dp, Color.Gray),
contentAlignment = Alignment.Center
) {
if (imageSource == null)
model.progressList.getOrNull(i)?.let { progress ->
if (progress < 0f)
Icon(Icons.Filled.BrokenImage, null)
else
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
LinearProgressIndicator(progress)
Text((i + 1).toString())
}
}
else
SubSampledImage(
modifier = Modifier.fillMaxSize(),
imageSource = imageSource,
state = states[i]
)
}
} }
} }
if (model.totalProgress != model.imageCount)
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
progress = model.progressList.map { abs(it) }.sum() / model.progressList.size,
color = colorResource(id = R.color.colorAccent)
)
} }
} }
} }

View File

@@ -1,6 +1,8 @@
package xyz.quaver.pupil.ui.composable package xyz.quaver.pupil.ui.composable
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -17,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -72,16 +75,17 @@ fun MiniFloatingActionButton(
} }
} }
FloatingActionButton( if (buttonScale > 0f)
modifier = Modifier FloatingActionButton(
.size(40.dp) modifier = Modifier
.scale(buttonScale), .size(40.dp)
onClick = { onClick?.invoke(item) }, .scale(buttonScale),
elevation = elevation, onClick = { onClick?.invoke(item) },
interactionSource = interactionSource elevation = elevation,
) { interactionSource = interactionSource
Icon(item.icon, contentDescription = null) ) {
} Icon(item.icon, contentDescription = null)
}
} }
} }

View File

@@ -21,8 +21,17 @@ package xyz.quaver.pupil.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.* import androidx.lifecycle.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI import org.kodein.di.android.x.closestDI
import org.kodein.di.direct import org.kodein.di.direct
@@ -33,6 +42,7 @@ import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.db.Bookmark import xyz.quaver.pupil.db.Bookmark
import xyz.quaver.pupil.db.History import xyz.quaver.pupil.db.History
import xyz.quaver.pupil.sources.Source import xyz.quaver.pupil.sources.Source
import xyz.quaver.pupil.util.NetworkCache
import xyz.quaver.pupil.util.source import xyz.quaver.pupil.util.source
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -40,6 +50,8 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI() override val di by closestDI()
private val cache: NetworkCache by instance()
private val logger = newLogger(LoggerFactory.default) private val logger = newLogger(LoggerFactory.default)
val isFullscreen = MutableLiveData(false) val isFullscreen = MutableLiveData(false)
@@ -58,8 +70,14 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
private val _title = MutableLiveData<String>() private val _title = MutableLiveData<String>()
val title = _title as LiveData<String> val title = _title as LiveData<String>
private val _images = MutableLiveData<List<String>>() private val totalProgressMutex = Mutex()
val images: LiveData<List<String>> = _images var totalProgress by mutableStateOf(0)
private set
var imageCount by mutableStateOf(0)
private set
val imageList = mutableStateListOf<Uri?>()
val progressList = mutableStateListOf<Float>()
val isBookmarked = Transformations.switchMap(MediatorLiveData<Pair<Source, String>>().apply { val isBookmarked = Transformations.switchMap(MediatorLiveData<Pair<Source, String>>().apply {
addSource(source) { source -> itemID.value?.let { itemID -> source to itemID } } addSource(source) { source -> itemID.value?.let { itemID -> source to itemID } }
@@ -119,12 +137,66 @@ class ReaderViewModel(app: Application) : AndroidViewModel(app), DIAware {
} }
viewModelScope.launch { viewModelScope.launch {
_images.postValue(withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
source.images(itemID) source.images(itemID)
}) }.let { images ->
imageCount = images.size
progressList.addAll(List(imageCount) { 0f })
imageList.addAll(List(imageCount) { null })
images.forEachIndexed { index, image ->
when (val scheme = image.takeWhile { it != ':' }) {
"http", "https" -> {
val file = cache.load {
url(image)
headers(source.getHeadersBuilderForImage(itemID, image))
}
val channel = cache.channels[image] ?: error("Channel is null")
if (channel.isClosedForReceive) {
imageList[index] = Uri.fromFile(file)
totalProgressMutex.withLock {
totalProgress++
}
} else {
channel.invokeOnClose { e ->
viewModelScope.launch {
if (e == null) {
imageList[index] = Uri.fromFile(file)
totalProgressMutex.withLock {
totalProgress++
}
} else {
TODO("Handle error")
}
}
}
launch {
kotlin.runCatching {
for (progress in channel) {
progressList[index] = progress
}
}
}
}
}
"content" -> {
progressList[index] = 1f
}
else -> throw IllegalArgumentException("Expected URL scheme 'http(s)' or 'content' but was '$scheme'")
}
}
}
} }
} }
fun error(index: Int) {
progressList[index] = -1f
}
fun toggleBookmark() { fun toggleBookmark() {
val bookmark = source.value?.let { source -> itemID.value?.let { itemID -> Bookmark(source, itemID) } } ?: return val bookmark = source.value?.let { source -> itemID.value?.let { itemID -> Bookmark(source, itemID) } } ?: return

View File

@@ -0,0 +1,106 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.content.Context
import com.google.firebase.crashlytics.FirebaseCrashlytics
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.util.*
import io.ktor.utils.io.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import org.kodein.di.DIAware
import org.kodein.di.android.closestDI
import org.kodein.di.instance
import xyz.quaver.hitomi.sha256
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.text.toByteArray
class NetworkCache(context: Context) : DIAware {
override val di by closestDI(context)
private val client: HttpClient by instance()
private val cacheDir = context.cacheDir
private val _channels = ConcurrentHashMap<String, Channel<Float>>()
val channels = _channels as Map<String, Channel<Float>>
private val requests = mutableMapOf<String, Job>()
private val networkScope = CoroutineScope(Executors.newFixedThreadPool(4).asCoroutineDispatcher())
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun load(requestBuilder: HttpRequestBuilder.() -> Unit): File = coroutineScope {
val request = HttpRequestBuilder().apply(requestBuilder)
val url = request.url.buildString()
val hash = sha256(url.toByteArray()).joinToString("") { "%02x".format(it) }
val file = File(cacheDir, "$hash.${url.takeLastWhile { it != '.' }}")
val progressChannel = if (_channels[url]?.isClosedForSend == false)
_channels[url]!!
else
Channel<Float>(1, BufferOverflow.DROP_OLDEST).also { _channels[url] = it }
if (file.exists())
progressChannel.close()
else
requests[url] = networkScope.launch {
kotlin.runCatching {
file.createNewFile()
client.request<HttpStatement>(request).execute { httpResponse ->
val responseChannel: ByteReadChannel = httpResponse.receive()
val contentLength = httpResponse.contentLength() ?: -1
var readBytes = 0f
file.outputStream().use { outputStream ->
while (!responseChannel.isClosedForRead) {
val packet = responseChannel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (!packet.isEmpty) {
val bytes = packet.readBytes()
outputStream.write(bytes)
readBytes += bytes.size
progressChannel.trySend(readBytes / contentLength)
}
}
}
progressChannel.close()
}
}.onFailure {
file.delete()
FirebaseCrashlytics.getInstance().recordException(it)
progressChannel.close(it)
}
}
return@coroutineScope file
}
}

View File

@@ -19,17 +19,33 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toAndroidRect
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.DirectDIAware import org.kodein.di.DirectDIAware
import org.kodein.di.direct import org.kodein.di.direct
import org.kodein.di.instance import org.kodein.di.instance
import xyz.quaver.graphics.subsampledimage.ImageSource
import xyz.quaver.graphics.subsampledimage.newBitmapRegionDecoder
import xyz.quaver.io.FileX
import xyz.quaver.io.util.inputStream
import xyz.quaver.pupil.db.AppDatabase import xyz.quaver.pupil.db.AppDatabase
import xyz.quaver.pupil.sources.ItemInfo import xyz.quaver.pupil.sources.ItemInfo
import xyz.quaver.pupil.sources.SourceEntries import xyz.quaver.pupil.sources.SourceEntries
import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.* import java.util.*
@@ -137,3 +153,19 @@ fun View.hide() {
fun View.show() { fun View.show() {
visibility = View.VISIBLE visibility = View.VISIBLE
} }
class FileXImageSource(file: FileX): ImageSource {
private val decoder = newBitmapRegionDecoder(file.inputStream()!!)
override val imageSize by lazy { Size(decoder.width.toFloat(), decoder.height.toFloat()) }
override fun decodeRegion(region: Rect, sampleSize: Int): ImageBitmap =
decoder.decodeRegion(region.toAndroidRect(), BitmapFactory.Options().apply {
inSampleSize = sampleSize
}).asImageBitmap()
}
@Composable
fun rememberFileXImageSource(file: FileX) = remember {
FileXImageSource(file)
}

View File

@@ -13,7 +13,7 @@ buildscript {
classpath("com.google.gms:google-services:4.3.10") classpath("com.google.gms:google-services:4.3.10")
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
classpath("com.google.firebase:firebase-crashlytics-gradle:2.7.1") classpath("com.google.firebase:firebase-crashlytics-gradle:2.8.0")
classpath("com.google.firebase:perf-plugin:1.4.0") classpath("com.google.firebase:perf-plugin:1.4.0")
classpath("com.google.android.gms:oss-licenses-plugin:0.10.4") classpath("com.google.android.gms:oss-licenses-plugin:0.10.4")
} }
@@ -23,6 +23,7 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
mavenLocal()
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
maven { url = uri("https://jitpack.io") } maven { url = uri("https://jitpack.io") }
} }