Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Basic Chat Support #224

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions CUSTOMIZING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,40 @@ If you're using Sessionize for your event, you can use the app pretty easily. Cu
Primarily you'll need to point to your data urls, change the data seed files for speakers/sessions/sponsors, and change the color settings.
Just follow these steps:

### Assets

- Change **colors** in `Colors.kt` (`shared-ui` module) and check `Theme.kt` if everything looks okay for Android
- Change **colors** for iOS in `Assets.xcassets` through Xcode: `NavBar_Background.colorset`, `Accent.colorset`, `AttendButton.colorset`
and `TabBar_Background.colorset`
- Change **icon** by changing `ic_launcher_foreground.xml`, `ic_launcher_background.xml` and `ic_launcher-playstore.png` for Android and
`AppIcon.appiconset` for iOS
- Change **launch screen image** by changing `ic_splash_screen.xml` for Android and `LaunchScreen_Icon.imageset` and
`LaunchScreen_Background.colorset` for iOS

### App Details
- Change app **name** `droidcon_title` in `strings.xml` and title text in `SessionListView.kt`
- Change **Bundle Name** in `Info.plist` and **Bundle Identifier** in `project.pbxproj`
- To avoid having to wait for the full verification process from apple use the already existing bundle id for the previous conference in
the city
- Change **applicationId** in `build.gradle.kts` (android module)
- To avoid having to wait for the full verification process from google use the already existing app id for the previous conference in
the city

### Data
- Change conference **time zone** and **time zone hash** in `Constants.kt`
- Change **sponsors collection name** and **Sessionize ids** in `Constants.kt`
- Change **icon** by changing `ic_launcher_foreground.xml`, `ic_launcher_background.xml` and `ic_launcher-playstore.png` for Android and
`AppIcon.appiconset` for iOS
- Change **launch screen image** by changing `ic_splash_screen.xml` for Android and `LaunchScreen_Icon.imageset` and
`LaunchScreen_Background.colorset` for iOS
- Change `schedule.json`, `speakers.json`, `sponsor_sessions.json` and `sponsors.json` by replacing them with new versions from Sessionize
and Firebase

### Chat

We are using [Stream](https://getstream.io/chat/) for chat features. In order to support them in your app you'll need to register for an App. From the [Stream Dashboard](https://dashboard.getstream.io/) you can customize your channels, chat types and various chat features.

##### Helpful Tips

* User the explorer in the [dashboard](https://dashboard.getstream.io/) to view the channels and messages through `Chat Messaging` -> `Explorer`. Here you can add/delete channels, add/remove members, and view/delete messages.
* By default Stream doesn't allow users to automatically join channels, however in Droidcon we want to have everyone join the channels for rooms. To support this option, you can go to the [dashboard](https://dashboard.getstream.io/), go to `Chat Messaging` -> `Roles & Permissions`, choose `user` as role and choose your channel level as scope. Then click edit and turn on `AddOwnChannelMembership`.

It would be super great if you could keep us in the about section of your app, though. We're a consulting company that turns
project revenue into open source stuff, so we need eyeballs. Thanks XOXO. Speaking of...

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,29 @@ This project has a pair of native mobile applications backed by the Sessionize d

## Building

#### Firebase

The apps need a Firebase account set up to run. You'll need to get the `google-services.json` and put it in `android/google-services.json` for Android, and
the `GoogleService-Info.plist` and put that in `ios/Droidcon/Droidcon/GoogleService-Info.plist` for iOS.

##### Authentication

Additionally for Firebase Authentication you'll need to pass in your client ID into the project.

For Android you'll need to add a `clientId` property to your `local.properties`.
For iOS you'll need to pass the clientId into your [URL Types](https://firebase.google.com/docs/auth/ios/google-signin#implement_google_sign-in).

#### Stream

In order to support Stream Chat you will need to register for [Stream Chat](https://getstream.io/chat/docs/), and pass your api key into the the codebase.

For Android you should add the `streamApiKey` property to your `local.properties`.
For iOS you should add the `streamApiKey` property to your `info.plist`.

## Customization

To customize the app, view the [Customizing Guide](CUSTOMIZING.md) for more details.

## Compose UI for both!

We're running a very early version of Compose UI for iOS as the iOS interface. It mostly shares the screen code with the Android app. While Native Compose UI is obviously experimental, it works surprisingly well.
Expand Down
8 changes: 8 additions & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ android {

val clientId = properties.getProperty("clientId", "")
buildConfigField("String", "CLIENT_ID", clientId)

val apiKey = properties.getProperty("streamApiKey", "")
buildConfigField("String", "STREAM_API_KEY", apiKey)
}
packaging {
resources.excludes.add("META-INF/*.kotlin_module")
Expand Down Expand Up @@ -102,4 +105,9 @@ dependencies {
implementation(libs.bundles.androidx.compose)

coreLibraryDesugaring(libs.android.desugar)

implementation("io.getstream:stream-chat-android-compose:6.0.8")
implementation("io.getstream:stream-chat-android-offline:6.0.8")

implementation("androidx.compose.material:material-icons-extended:1.6.0-alpha08")
}
28 changes: 19 additions & 9 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />

<application
android:name="co.touchlab.droidcon.android.MainApp"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Droidcon.Starting"
android:name="co.touchlab.droidcon.android.MainApp">
android:theme="@style/Theme.Droidcon.Starting">
<activity
android:name="co.touchlab.droidcon.android.MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity
android:name=".android.chat.ChannelActivity"
android:windowSoftInputMode="adjustResize"/>

<receiver android:name="co.touchlab.droidcon.service.NotificationPublisher" />

<receiver
Expand All @@ -32,6 +40,8 @@
</intent-filter>
</receiver>

<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="false" />
</application>
</manifest>
24 changes: 22 additions & 2 deletions android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import co.touchlab.droidcon.R
import co.touchlab.droidcon.UserData
import co.touchlab.droidcon.android.chat.ChatManager
import co.touchlab.droidcon.android.chat.ChatView
import co.touchlab.droidcon.android.service.impl.AndroidGoogleSignInService
import co.touchlab.droidcon.application.service.NotificationSchedulingService
import co.touchlab.droidcon.application.service.NotificationService
Expand Down Expand Up @@ -71,6 +74,17 @@ class MainActivity : ComponentActivity(), KoinComponent {
email = user.email,
pictureUrl = user.photoUrl?.toString(),
)
if (!ChatManager.isConnected) {
ChatManager.initChat(
applicationContext = applicationContext,
userData = UserData(
id = user.uid,
name = user.displayName,
email = user.email,
pictureUrl = user.photoUrl?.toString(),
),
)
}
Comment on lines +77 to +87

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the chat only enabled for authenticated people? If yes, should we create a placeholder screen for the chat tab?

Reason: if I'm not authenticated, the app crashes

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic does not update the current user when switching accounts.

} ?: run { authenticationService.clearCredentials() }
}

Expand Down Expand Up @@ -105,12 +119,16 @@ class MainActivity : ComponentActivity(), KoinComponent {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

auth = Firebase.auth
auth.addAuthStateListener(firebaseAuthListener)
installSplashScreen()
AppChecker.checkTimeZoneHash()

(googleSignInService as AndroidGoogleSignInService).setActivity(this, firebaseIntentResultLauncher)
(googleSignInService as AndroidGoogleSignInService).setActivity(
this,
firebaseIntentResultLauncher
)
analyticsService.logEvent(AnalyticsService.EVENT_STARTED)

applicationViewModel.lifecycle.removeFromParent()
Expand All @@ -125,7 +143,9 @@ class MainActivity : ComponentActivity(), KoinComponent {
WindowCompat.setDecorFitsSystemWindows(window, false)

setContent {
MainView(viewModel = applicationViewModel)
MainView(viewModel = applicationViewModel) {
ChatView()
}
val showSplashScreen by applicationViewModel.showSplashScreen.collectAsState()
Crossfade(targetState = showSplashScreen) { shouldShowSplashScreen ->
if (shouldShowSplashScreen) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package co.touchlab.droidcon.android.chat

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import io.getstream.chat.android.compose.ui.messages.MessagesScreen
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.compose.viewmodel.messages.MessagesViewModelFactory

class ChannelActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val channelId = intent.getStringExtra(KEY_CHANNEL_ID)!!
actionBar?.hide()

setContent {
ChatTheme {
MessagesScreen(
viewModelFactory = MessagesViewModelFactory(
context = this,
channelId = channelId,
messageLimit = 30
),
onBackPressed = { finish() }
)
}
}
}

companion object {
private const val KEY_CHANNEL_ID = "channelId"

fun getIntent(context: Context, channelId: String): Intent {
return Intent(context, ChannelActivity::class.java).apply {
putExtra(KEY_CHANNEL_ID, channelId)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package co.touchlab.droidcon.android.chat

import android.content.Context
import co.touchlab.droidcon.BuildConfig
import co.touchlab.droidcon.UserData
import co.touchlab.kermit.Logger
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.logger.ChatLogLevel
import io.getstream.chat.android.models.User
import io.getstream.chat.android.offline.plugin.factory.StreamOfflinePluginFactory
import io.getstream.chat.android.state.plugin.config.StatePluginConfig
import io.getstream.chat.android.state.plugin.factory.StreamStatePluginFactory
import io.getstream.result.call.enqueue

object ChatManager {
private val chatLogger: Logger = Logger.withTag("ChatManager")

var isConnected: Boolean = false
private set

fun initChat(
applicationContext: Context,
userData: UserData,
) {
if (!isConnected) {
chatLogger.i { "Initializing Chat" }
val offlinePluginFactory = StreamOfflinePluginFactory(appContext = applicationContext)
val statePluginFactory =
StreamStatePluginFactory(
config = StatePluginConfig(),
appContext = applicationContext
)

val client = ChatClient.Builder(BuildConfig.STREAM_API_KEY, applicationContext)
.withPlugins(offlinePluginFactory, statePluginFactory)
.logLevel(ChatLogLevel.ALL) // TODO: Set to NOTHING in prod
.build()

chatLogger.v { "Built the client, adding user" }
val user = User(
id = userData.id,
name = userData.name ?: "Unknown Name",
image = userData.pictureUrl ?: "",
)
val token = client.devToken(user.id) // TODO: Replace with Token from backend

chatLogger.v { "Connecting User" }
client.connectUser(user = user, token = token).enqueue(
onSuccess = {
chatLogger.v { "Successfully Connected!" }
isConnected = true
joinDefaultChannels(user.id)
},
onError = {
chatLogger.e { "Error Connecting! $it" }
isConnected = false
}
)
}
}

private fun joinDefaultChannels(id: String) {
chatLogger.i { "Joining the General Channel" }

// TODO: Make the Auto-Join Channel(s) configurable
val channelClient = ChatClient.instance().channel("messaging", "general")
channelClient.addMembers(listOf(id)).enqueue(
onSuccess = {
chatLogger.v { "Successfully Joined The General channel" }
},
onError = {
chatLogger.e { "Error Joining the General channel $it" }
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package co.touchlab.droidcon.android.chat

import android.content.Context
import android.os.Bundle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat.startActivity
import co.touchlab.droidcon.R
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.compose.ui.channels.ChannelsScreen
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.models.InitializationState

@Composable
internal fun ChatView() {
val context: Context = LocalContext.current

Column(modifier = Modifier.fillMaxSize()) {
ChatTheme {
val client = ChatClient.instance()
val clientInitialisationState by client.clientState.initializationState.collectAsState()

when (clientInitialisationState) {
InitializationState.COMPLETE -> {
ChannelsScreen(
title = stringResource(id = R.string.app_name),
isShowingHeader = false,
isShowingSearch = true,
onItemClick = {
startActivity(
context,
ChannelActivity.getIntent(context, it.cid),
Bundle(),
)
},
onBackPressed = { }
)
}

InitializationState.INITIALIZING -> {
Text(text = "Initialising...")
}

InitializationState.NOT_INITIALIZED -> {
Text(text = "Not initialized...")
}
Comment on lines +51 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add an "action" to initialize or retry on failure?

}
}
}
}
Loading
Loading