Start implementing modmail using Kotlin + Compose.

This commit is contained in:
Docile-Alligator
2024-11-14 23:44:03 -05:00
parent c24813bc7f
commit 5ef543c69b
15 changed files with 518 additions and 14 deletions

View File

@ -1,5 +1,9 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-parcelize'
id 'kotlin-kapt'
id 'com.google.devtools.ksp'
}
android {
@ -39,6 +43,18 @@ android {
targetCompatibility JavaVersion.VERSION_11
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
kotlinOptions {
jvmTarget = '11'
}
kapt {
correctErrorTypes = true
}
lint {
disable 'MissingTranslation'
}
@ -52,6 +68,7 @@ android {
buildFeatures {
buildConfig = true
viewBinding = true
compose = true
}
namespace 'ml.docilealligator.infinityforreddit'
}
@ -63,6 +80,7 @@ dependencies {
implementation 'androidx.browser:browser:1.8.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.activity:activity:1.9.0'
def lifecycleVersion = "2.7.0"
@ -75,11 +93,13 @@ dependencies {
def pagingVersion = '3.3.0'
implementation "androidx.paging:paging-runtime:$pagingVersion"
implementation "androidx.paging:paging-guava:$pagingVersion"
implementation "androidx.paging:paging-compose:3.3.4"
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
def roomVersion = "2.6.1"
implementation "androidx.room:room-runtime:$roomVersion"
annotationProcessor "androidx.room:room-compiler:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation "androidx.room:room-guava:$roomVersion"
implementation 'androidx.viewpager2:viewpager2:1.1.0'
implementation 'androidx.work:work-runtime:2.9.0'
@ -108,6 +128,7 @@ dependencies {
def daggerVersion = '2.51.1'
implementation "com.google.dagger:dagger:$daggerVersion"
annotationProcessor "com.google.dagger:dagger-compiler:$daggerVersion"
ksp "com.google.dagger:dagger-compiler:$daggerVersion"
// Binding
compileOnly 'com.android.databinding:viewbinding:8.5.1'
@ -116,6 +137,7 @@ dependencies {
def eventbusVersion = "3.3.1"
implementation "org.greenrobot:eventbus:$eventbusVersion"
annotationProcessor "org.greenrobot:eventbus-annotation-processor:$eventbusVersion"
kapt "org.greenrobot:eventbus-annotation-processor:$eventbusVersion"
// TransactionTooLargeException avoidance
implementation 'com.github.livefront:bridge:v2.0.2'
@ -125,6 +147,7 @@ dependencies {
def stateVersion = "1.4.1"
implementation "com.evernote:android-state:$stateVersion"
annotationProcessor "com.evernote:android-state-processor:$stateVersion"
kapt "com.evernote:android-state-processor:$stateVersion"
// Object to JSON
// NOTE: Replace with Squareup's Moshi?
@ -146,6 +169,7 @@ dependencies {
def glideVersion = "4.16.0"
implementation "com.github.bumptech.glide:glide:$glideVersion"
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
ksp "com.github.bumptech.glide:compiler:$glideVersion"
implementation 'jp.wasabeef:glide-transformations:4.3.0'
implementation 'com.github.santalu:aspect-ratio-imageview:1.0.9'
implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.29'
@ -193,6 +217,32 @@ dependencies {
implementation 'com.giphy.sdk:ui:2.3.15'
// Compose
def composeBom = platform('androidx.compose:compose-bom:2024.10.01')
implementation composeBom
androidTestImplementation composeBom
// Choose one of the following:
// Material Design 3
implementation 'androidx.compose.material3:material3'
implementation("androidx.compose.material3.adaptive:adaptive")
implementation("androidx.compose.material3.adaptive:adaptive-layout")
implementation("androidx.compose.material3.adaptive:adaptive-navigation")
// Android Studio Preview support
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
// UI Tests
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
// Optional - Integration with activities
implementation 'androidx.activity:activity-compose:1.9.3'
// Optional - Integration with ViewModels
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
// Optional - Integration with LiveData
implementation 'androidx.compose.runtime:runtime-livedata'
/**** Builds and flavors ****/
// debugImplementation because LeakCanary should only run in debug builds.

View File

@ -36,13 +36,17 @@
android:usesCleartextTraffic="true"
tools:replace="android:label">
<activity
android:name=".activities.LoginChromeCustomTabActivity"
android:label="@string/login_activity_label"
android:name=".activities.ModmailActivity"
android:exported="false"
android:parentActivityName=".activities.MainActivity"
android:theme="@style/AppTheme.Slidable"
android:theme="@style/AppTheme.Slidable" />
<activity
android:name=".activities.LoginChromeCustomTabActivity"
android:exported="true"
android:label="@string/login_activity_label"
android:launchMode="singleTop"
android:exported="true">
android:parentActivityName=".activities.MainActivity"
android:theme="@style/AppTheme.Slidable">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -53,7 +57,6 @@
android:host="localhost"
android:scheme="infinity" />
</intent-filter>
</activity>
<activity
android:name=".activities.CommentFilterUsageListingActivity"
@ -156,8 +159,8 @@
<service
android:name=".services.DownloadMediaService"
android:enabled="true"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<activity
android:name=".activities.ViewRedditGalleryActivity"
@ -174,8 +177,8 @@
<service
android:name=".services.DownloadRedditVideoService"
android:enabled="true"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<activity
android:name=".activities.ViewPrivateMessagesActivity"
@ -454,13 +457,13 @@
<service
android:name=".services.SubmitPostService"
android:enabled="true"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".services.EditProfileService"
android:enabled="true"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<receiver android:name=".broadcastreceivers.DownloadedMediaDeleteActionBroadcastReceiver" />
</application>

View File

@ -31,6 +31,7 @@ import ml.docilealligator.infinityforreddit.activities.LockScreenActivity;
import ml.docilealligator.infinityforreddit.activities.LoginActivity;
import ml.docilealligator.infinityforreddit.activities.LoginChromeCustomTabActivity;
import ml.docilealligator.infinityforreddit.activities.MainActivity;
import ml.docilealligator.infinityforreddit.activities.ModmailActivity;
import ml.docilealligator.infinityforreddit.activities.PostFilterPreferenceActivity;
import ml.docilealligator.infinityforreddit.activities.PostFilterUsageListingActivity;
import ml.docilealligator.infinityforreddit.activities.PostGalleryActivity;
@ -312,6 +313,8 @@ public interface AppComponent {
void inject(LoginChromeCustomTabActivity loginChromeCustomTabActivity);
void inject(ModmailActivity modMailActivity);
@Component.Factory
interface Factory {
AppComponent create(@BindsInstance Application application);

View File

@ -316,6 +316,9 @@ public class MainActivity extends BaseActivity implements SortTypeSelectionCallb
}*/
initializeNotificationAndBindView();
Intent intent = new Intent(this, ModmailActivity.class);
startActivity(intent);
}
@Override

View File

@ -0,0 +1,179 @@
package ml.docilealligator.infinityforreddit.activities
import android.content.SharedPreferences
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import ml.docilealligator.infinityforreddit.Infinity
import ml.docilealligator.infinityforreddit.R
import ml.docilealligator.infinityforreddit.customtheme.CustomThemeWrapper
import ml.docilealligator.infinityforreddit.mod.Conversation
import ml.docilealligator.infinityforreddit.mod.ModMailConversationViewModel
import ml.docilealligator.infinityforreddit.mod.ModMessage
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Named
@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class)
class ModmailActivity : BaseActivity() {
@Inject
@Named("oauth")
lateinit var mOauthRetrofit: Retrofit
@Inject
@Named("default")
lateinit var mSharedPreferences: SharedPreferences
@Inject
@Named("current_account")
lateinit var mCurrentAccountSharedPreferences: SharedPreferences
@Inject
lateinit var mCustomThemeWrapper: CustomThemeWrapper
lateinit var conversationViewModel: ModMailConversationViewModel
override fun onCreate(savedInstanceState: Bundle?) {
(application as Infinity).appComponent.inject(this)
super.onCreate(savedInstanceState)
if (accessToken == null) {
Toast.makeText(this, R.string.login_first, Toast.LENGTH_SHORT).show()
finish()
return
}
enableEdgeToEdge()
conversationViewModel = ViewModelProvider.create(this, ModMailConversationViewModel.Factory(mOauthRetrofit, accessToken!!, mSharedPreferences))[ModMailConversationViewModel::class]
setContent {
Scaffold(
topBar = {
TopAppBar(
colors = topAppBarColors(
containerColor = Color(mCustomThemeWrapper.colorPrimary),
titleContentColor = Color(mCustomThemeWrapper.toolbarPrimaryTextAndIconColor)
),
title = {
Text(getString(R.string.modmail_activity_label))
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val navigator = rememberListDetailPaneScaffoldNavigator<Conversation>()
BackHandler(navigator.canNavigateBack()) {
navigator.navigateBack()
}
ListDetailPaneScaffold(
modifier = Modifier.padding(16.dp),
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ConversationListView(conversationViewModel.flow.collectAsLazyPagingItems())
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.content?.let {
ConversationDetailsView(it)
}
}
}
)
}
}
}
}
override fun getDefaultSharedPreferences(): SharedPreferences {
return mSharedPreferences
}
override fun getCurrentAccountSharedPreferences(): SharedPreferences {
return mCurrentAccountSharedPreferences
}
override fun getCustomThemeWrapper(): CustomThemeWrapper {
return mCustomThemeWrapper
}
override fun applyCustomTheme() {
}
@Composable
fun ConversationListView(pagingItems: LazyPagingItems<Conversation>) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(count = pagingItems.itemCount) { index: Int ->
val conversation = pagingItems[index]
conversation?.let {
ConversationView(it)
}
}
}
}
@Composable
fun ConversationView(conversation: Conversation) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
conversation.owner?.displayName?.let {
Text(text = it, color = Color(mCustomThemeWrapper.subreddit))
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
for (author in conversation.authors) {
author.name?.let {
Text(text = it, color = Color(mCustomThemeWrapper.username))
}
}
}
conversation.subject?.let {
Text(text = it)
}
}
}
@Composable
fun ConversationDetailsView(conversation: Conversation) {
}
@Composable
fun MessageView(message: ModMessage) {
}
}

View File

@ -0,0 +1,15 @@
package ml.docilealligator.infinityforreddit.apis
import retrofit2.Call
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.HeaderMap
import retrofit2.http.Query
interface RedditAPIKt {
@GET("/api/mod/conversations")
suspend fun getModMailConversations(
@HeaderMap headers: Map<String, String>,
@Query("after") after: String?
): Response<String>
}

View File

@ -0,0 +1,18 @@
package ml.docilealligator.infinityforreddit.mod
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
data class Author(
@SerializedName("isMod") var isMod: Boolean? = null,
@SerializedName("isAdmin") var isAdmin: Boolean? = null,
@SerializedName("name") var name: String? = null,
@SerializedName("isOp") var isOp: Boolean? = null,
@SerializedName("isParticipant") var isParticipant: Boolean? = null,
@SerializedName("isApproved") var isApproved: Boolean? = null,
@SerializedName("isHidden") var isHidden: Boolean? = null,
@SerializedName("id") var id: String? = null,
@SerializedName("isDeleted") var isDeleted: Boolean? = null
) : Parcelable

View File

@ -0,0 +1,55 @@
package ml.docilealligator.infinityforreddit.mod
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
data class Conversation(
@SerializedName("isAuto") var isAuto: Boolean? = null,
@SerializedName("participant") var participant: Participant? = Participant(),
@SerializedName("objIds") var objIds: ArrayList<ObjId> = arrayListOf(),
@SerializedName("isRepliable") var isRepliable: Boolean? = null,
@SerializedName("lastUserUpdate") var lastUserUpdate: String? = null,
@SerializedName("isInternal") var isInternal: Boolean? = null,
@SerializedName("lastModUpdate") var lastModUpdate: String? = null,
@SerializedName("authors") var authors: ArrayList<Author> = arrayListOf(),
@SerializedName("lastUpdated") var lastUpdated: String? = null,
@SerializedName("legacyFirstMessageId") var legacyFirstMessageId: String? = null,
@SerializedName("state") var state: Int? = null,
@SerializedName("conversationType") var conversationType: String? = null,
@SerializedName("lastUnread") var lastUnread: String? = null,
@SerializedName("owner") var owner: Owner? = Owner(),
@SerializedName("subject") var subject: String? = null,
@SerializedName("id") var id: String? = null,
@SerializedName("isHighlighted") var isHighlighted: Boolean? = null,
@SerializedName("numMessages") var numMessages: Int? = null
): Parcelable {
val messages: MutableList<ModMessage> = mutableListOf()
}
@Parcelize
data class Participant(
@SerializedName("isMod") var isMod: Boolean? = null,
@SerializedName("isAdmin") var isAdmin: Boolean? = null,
@SerializedName("name") var name: String? = null,
@SerializedName("isOp") var isOp: Boolean? = null,
@SerializedName("isParticipant") var isParticipant: Boolean? = null,
@SerializedName("isApproved") var isApproved: Boolean? = null,
@SerializedName("isHidden") var isHidden: Boolean? = null,
@SerializedName("id") var id: String? = null,
@SerializedName("isDeleted") var isDeleted: Boolean? = null
): Parcelable
@Parcelize
data class ObjId(
@SerializedName("id") var id: String? = null,
@SerializedName("key") var key: String? = null
): Parcelable
@Parcelize
data class Owner(
@SerializedName("displayName") var displayName: String? = null,
@SerializedName("type") var type: String? = null,
@SerializedName("id") var id: String? = null
): Parcelable

View File

@ -0,0 +1,42 @@
package ml.docilealligator.infinityforreddit.mod
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import org.json.JSONObject
import java.io.IOException
data class ModMail(
@SerializedName("viewerId") var viewerId: String? = null
) {
lateinit var conversations: MutableList<Conversation>;
lateinit var messages: MutableList<ModMessage>;
val conversationIds: MutableList<String> = arrayListOf()
fun parseConversations(conversationsJSONObject: JSONObject, gson: Gson) {
for (conversationId in conversationIds) {
try {
conversations.add(gson.fromJson(conversationsJSONObject.getString(conversationId), Conversation::class.java))
} catch (ignore: IOException) {
ignore.printStackTrace()
}
}
}
fun parseModMessages(messagesJSONObject: JSONObject, gson: Gson) {
for (conversation in conversations) {
for (objId in conversation.objIds) {
objId.key?.let { key ->
if (key == "messages") {
objId.id?.let { id ->
try {
messages.add(gson.fromJson(messagesJSONObject.getString(id), ModMessage::class.java))
} catch (ignore: IOException) {
ignore.printStackTrace()
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,73 @@
package ml.docilealligator.infinityforreddit.mod
import android.content.SharedPreferences
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ml.docilealligator.infinityforreddit.apis.RedditAPI
import ml.docilealligator.infinityforreddit.apis.RedditAPIKt
import ml.docilealligator.infinityforreddit.utils.APIUtils
import ml.docilealligator.infinityforreddit.utils.JSONUtils
import org.json.JSONObject
import retrofit2.Retrofit
import java.io.IOException
class ModMailConversationPagingSource(val retrofit: Retrofit, val accessToken: String, val sharedPreferences: SharedPreferences): PagingSource<String, Conversation>() {
override fun getRefreshKey(state: PagingState<String, Conversation>): String? {
return null;
}
override suspend fun load(params: LoadParams<String>): LoadResult<String, Conversation> {
try {
val response = retrofit.create(RedditAPIKt::class.java)
.getModMailConversations(APIUtils.getOAuthHeader(accessToken), params.key)
if (response.isSuccessful) {
response.body()?.let {
val json = JSONObject(it)
val conversationIdsArray = json.getJSONArray(JSONUtils.CONVERSATION_IDS_KEY)
if (conversationIdsArray.length() == 0) {
return LoadResult.Page(listOf(), null, null)
}
val gson = Gson()
val conversations: MutableList<Conversation> = mutableListOf()
val messagesJSONObject = json.getJSONObject(JSONUtils.MESSAGES_KEY)
for (i in 0 until conversationIdsArray.length()) {
val conversationId = conversationIdsArray.getString(i)
try {
conversations.add(gson.fromJson(json.getJSONObject(JSONUtils.CONVERSATIONS_KEY).getString(conversationId), Conversation::class.java).apply {
for (objId in objIds) {
objId.key?.let { key ->
if (key == "messages") {
objId.id?.let { id ->
try {
messages.add(gson.fromJson(messagesJSONObject.getString(id), ModMessage::class.java))
} catch (ignore: IOException) {
ignore.printStackTrace()
}
}
}
}
}
})
} catch (ignore: IOException) {
ignore.printStackTrace()
}
}
return LoadResult.Page(
conversations, null, conversationIdsArray.getString(conversationIdsArray.length() - 1)
)
}
}
} catch (e: IOException) {
e.printStackTrace()
}
return LoadResult.Error(Exception("Error getting response"))
}
}

View File

@ -0,0 +1,40 @@
package ml.docilealligator.infinityforreddit.mod
import android.content.SharedPreferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import retrofit2.Retrofit
class ModMailConversationViewModel(
oauthRetrofit: Retrofit,
accessToken: String,
sharedPreferences: SharedPreferences
) : ViewModel() {
private val pagingSource: ModMailConversationPagingSource =
ModMailConversationPagingSource(oauthRetrofit, accessToken, sharedPreferences)
val flow = Pager(
PagingConfig(20)
) {
pagingSource
}.flow.cachedIn(viewModelScope)
fun refresh() {
pagingSource.invalidate()
}
@Suppress("UNCHECKED_CAST")
class Factory(
private val oauthRetrofit: Retrofit,
private val accessToken: String,
private val sharedPreferences: SharedPreferences
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ModMailConversationViewModel(oauthRetrofit, accessToken, sharedPreferences) as T
}
}
}

View File

@ -0,0 +1,13 @@
package ml.docilealligator.infinityforreddit.mod
import com.google.gson.annotations.SerializedName
data class ModMessage(
@SerializedName("body") var body: String? = null,
@SerializedName("author") var author: Author? = Author(),
@SerializedName("isInternal") var isInternal: Boolean? = null,
@SerializedName("date") var date: String? = null,
@SerializedName("bodyMarkdown") var bodyMarkdown: String? = null,
@SerializedName("id") var id: String? = null,
@SerializedName("participatingAs") var participatingAs: String? = null
)

View File

@ -197,6 +197,11 @@ public class JSONUtils {
public static final String VARIANTS_KEY = "variants";
public static final String PAGE_KEY = "page";
public static final String SEND_REPLIES_KEY = "send_replies";
public static final String CONVERSATIONS_KEY = "conversations";
public static final String VIEWER_ID_KEY = "viewerId";
public static final String CONVERSATION_IDS_KEY = "conversationIds";
public static final String OBJ_IDS_KEY = "objIds";
public static final String MESSAGES_KEY = "messages";
@Nullable
public static Map<String, MediaMetadata> parseMediaMetadata(JSONObject data) {

View File

@ -47,6 +47,7 @@
<string name="subscription_activity_label">Subscription</string>
<string name="comment_filter_preference_activity_label">Comment Filter</string>
<string name="customize_comment_filter_activity_label">Customize Comment Filter</string>
<string name="modmail_activity_label">Modmail</string>
<string name="navigation_drawer_open">Open navigation drawer</string>
<string name="navigation_drawer_close">Close navigation drawer</string>
@ -1524,5 +1525,7 @@
<string name="download_gif">Download Gif</string>
<string name="download_video">Download Video</string>
<string name="download_all_gallery_images">Download All Gallery Images</string>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
</resources>

View File

@ -7,6 +7,8 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.5.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24'
classpath "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:1.9.24-1.0.20"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files