├── settings.gradle
├── screenshots
├── chat.png
├── main.png
├── bubble.png
└── icon-web.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── app
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── drawable-nodpi
│ │ │ │ ├── cat.jpg
│ │ │ │ ├── dog.jpg
│ │ │ │ ├── parrot.jpg
│ │ │ │ ├── sheep.jpg
│ │ │ │ └── sheep_full.jpg
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── values
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ ├── ids.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── dimens.xml
│ │ │ │ ├── styles.xml
│ │ │ │ └── strings.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── ic_send.xml
│ │ │ │ ├── ic_message.xml
│ │ │ │ ├── ic_open_in_new.xml
│ │ │ │ ├── ic_voice_call.xml
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ ├── message_incoming.xml
│ │ │ │ └── message_outgoing.xml
│ │ │ ├── menu
│ │ │ │ └── chat.xml
│ │ │ ├── layout
│ │ │ │ ├── bubble_activity.xml
│ │ │ │ ├── main_fragment.xml
│ │ │ │ ├── message_item.xml
│ │ │ │ ├── photo_fragment.xml
│ │ │ │ ├── chat_item.xml
│ │ │ │ ├── voice_call_activity.xml
│ │ │ │ ├── chat_fragment.xml
│ │ │ │ └── main_activity.xml
│ │ │ └── transition
│ │ │ │ ├── slide_top.xml
│ │ │ │ ├── slide_bottom.xml
│ │ │ │ └── app_bar.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── example
│ │ │ │ └── android
│ │ │ │ └── bubbles
│ │ │ │ ├── ui
│ │ │ │ ├── main
│ │ │ │ │ ├── MainViewModel.kt
│ │ │ │ │ ├── MainFragment.kt
│ │ │ │ │ └── ContactAdapter.kt
│ │ │ │ ├── photo
│ │ │ │ │ └── PhotoFragment.kt
│ │ │ │ └── chat
│ │ │ │ │ ├── ChatViewModel.kt
│ │ │ │ │ ├── MessageAdapter.kt
│ │ │ │ │ └── ChatFragment.kt
│ │ │ │ ├── data
│ │ │ │ ├── Message.kt
│ │ │ │ ├── Chat.kt
│ │ │ │ ├── Contact.kt
│ │ │ │ ├── ChatRepository.kt
│ │ │ │ └── NotificationHelper.kt
│ │ │ │ ├── NavigationController.kt
│ │ │ │ ├── VoiceCallActivity.kt
│ │ │ │ ├── BubbleActivity.kt
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── android
│ │ │ └── bubbles
│ │ │ ├── LiveDataTestUtils.kt
│ │ │ ├── ui
│ │ │ ├── main
│ │ │ │ └── MainViewModelTest.kt
│ │ │ └── chat
│ │ │ │ └── ChatViewModelTest.kt
│ │ │ └── data
│ │ │ └── TestChatRepository.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── example
│ │ └── android
│ │ └── bubbles
│ │ ├── MainActivityTest.kt
│ │ └── BubbleActivityTest.kt
├── proguard-rules.pro
└── build.gradle
├── README.md
├── gradle.properties
├── gradlew.bat
└── gradlew
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 |
--------------------------------------------------------------------------------
/screenshots/chat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/screenshots/chat.png
--------------------------------------------------------------------------------
/screenshots/main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/screenshots/main.png
--------------------------------------------------------------------------------
/screenshots/bubble.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/screenshots/bubble.png
--------------------------------------------------------------------------------
/screenshots/icon-web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/screenshots/icon-web.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable-nodpi/cat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/drawable-nodpi/cat.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable-nodpi/dog.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/drawable-nodpi/dog.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable-nodpi/parrot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/drawable-nodpi/parrot.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable-nodpi/sheep.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/drawable-nodpi/sheep.jpg
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-nodpi/sheep_full.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/drawable-nodpi/sheep_full.jpg
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/googlearchive/android-Bubbles/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #1C7A71
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.3-all.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Android Bubbles Sample
2 | ======================
3 |
4 | This repo has been migrated to [github.com/android/user-interface-samples][1]. Please check that repo for future updates. Thank you!
5 |
6 | [1]: https://github.com/android/user-interface-samples
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_send.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/chat.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_message.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_open_in_new.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_voice_call.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ids.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
8 |
10 |
12 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/bubble_activity.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
22 |
--------------------------------------------------------------------------------
/app/src/main/res/transition/slide_top.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
22 |
--------------------------------------------------------------------------------
/app/src/main/res/transition/slide_bottom.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
22 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 | #008577
19 | #00574B
20 | #D81B60
21 |
22 | #FBE9E7
23 | #EEEEEE
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/transition/app_bar.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/main_fragment.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/message_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
21 |
22 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/photo_fragment.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
27 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 | 16dp
19 | 8dp
20 | 64dp
21 | 4dp
22 | 16dp
23 | 16dp
24 | 24dp
25 | 400dp
26 |
27 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx1536m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/ui/main/MainViewModel.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.main
17 |
18 | import android.app.Application
19 | import androidx.lifecycle.AndroidViewModel
20 | import com.example.android.bubbles.data.ChatRepository
21 | import com.example.android.bubbles.data.DefaultChatRepository
22 |
23 | class MainViewModel @JvmOverloads constructor(
24 | application: Application,
25 | repository: ChatRepository = DefaultChatRepository.getInstance(application)
26 | ) : AndroidViewModel(application) {
27 |
28 | /**
29 | * All the contacts.
30 | */
31 | val contacts = repository.getContacts()
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
24 |
25 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/data/Message.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.data
17 |
18 | import androidx.annotation.DrawableRes
19 |
20 | data class Message(
21 | val id: Long,
22 | val sender: Long,
23 | val text: String,
24 | @DrawableRes
25 | val photo: Int?,
26 | val timestamp: Long
27 | ) {
28 |
29 | val isIncoming: Boolean
30 | get() = sender != 0L
31 |
32 | class Builder {
33 | var id: Long? = null
34 | var sender: Long? = null
35 | var text: String? = null
36 | var photo: Int? = null
37 | var timestamp: Long? = null
38 | fun build() = Message(id!!, sender!!, text!!, photo, timestamp!!)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/message_incoming.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
19 | -
20 |
25 |
26 |
27 |
28 |
29 |
30 | -
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/message_outgoing.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
19 | -
20 |
25 |
26 |
27 |
28 |
29 |
30 | -
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 | Bubbles
19 | Show as Bubble
20 | Profile icon
21 | Make a voice call (dummy)
22 | Send
23 | Photo
24 | Type a message…
25 | New messages
26 | All new incoming messages.
27 | Chat with %s
28 | This is a dummy voice call screen.
29 |
30 |
--------------------------------------------------------------------------------
/app/src/test/java/com/example/android/bubbles/LiveDataTestUtils.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles
17 |
18 | import androidx.lifecycle.LiveData
19 | import androidx.lifecycle.Observer
20 | import androidx.test.platform.app.InstrumentationRegistry
21 | import java.util.concurrent.CountDownLatch
22 | import java.util.concurrent.TimeUnit
23 |
24 | /**
25 | * Observes this [LiveData] and returns the value.
26 | *
27 | * @throws NullPointerException if the observed value is null.
28 | */
29 | fun LiveData.observedValue(): T {
30 | var result: T? = null
31 | val latch = CountDownLatch(1)
32 | val observer = Observer {
33 | result = it
34 | latch.countDown()
35 | }
36 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
37 | observeForever(observer)
38 | }
39 | latch.await(3000L, TimeUnit.MILLISECONDS)
40 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
41 | removeObserver(observer)
42 | }
43 | return result!!
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/data/Chat.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.data
17 |
18 | typealias ChatThreadListener = (List) -> Unit
19 |
20 | class Chat(val contact: Contact) {
21 |
22 | private val listeners = mutableListOf()
23 |
24 | private val _messages = mutableListOf(
25 | Message(1L, contact.id, "Send me a message", null, System.currentTimeMillis()),
26 | Message(2L, contact.id, "I will reply in 5 seconds", null, System.currentTimeMillis())
27 | )
28 | val messages: List
29 | get() = _messages
30 |
31 | fun addListener(listener: ChatThreadListener) {
32 | listeners.add(listener)
33 | }
34 |
35 | fun removeListener(listener: ChatThreadListener) {
36 | listeners.remove(listener)
37 | }
38 |
39 | fun addMessage(builder: Message.Builder) {
40 | builder.id = _messages.last().id + 1
41 | _messages.add(builder.build())
42 | listeners.forEach { listener -> listener(_messages) }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/ui/photo/PhotoFragment.kt:
--------------------------------------------------------------------------------
1 | package com.example.android.bubbles.ui.photo
2 |
3 | import android.os.Bundle
4 | import android.transition.Fade
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import android.widget.ImageView
9 | import androidx.annotation.DrawableRes
10 | import androidx.fragment.app.Fragment
11 | import com.example.android.bubbles.R
12 | import com.example.android.bubbles.getNavigationController
13 |
14 | /**
15 | * Shows the specified [DrawableRes] as a full-screen photo.
16 | */
17 | class PhotoFragment : Fragment() {
18 |
19 | companion object {
20 | private const val ARG_PHOTO = "photo"
21 |
22 | fun newInstance(@DrawableRes photo: Int) = PhotoFragment().apply {
23 | arguments = Bundle().apply {
24 | putInt(ARG_PHOTO, photo)
25 | }
26 | }
27 | }
28 |
29 | override fun onCreate(savedInstanceState: Bundle?) {
30 | super.onCreate(savedInstanceState)
31 | enterTransition = Fade()
32 | }
33 |
34 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
35 | return inflater.inflate(R.layout.photo_fragment, container, false)
36 | }
37 |
38 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
39 | val photoResId = arguments?.getInt(ARG_PHOTO)
40 | if (photoResId == null) {
41 | fragmentManager?.popBackStack()
42 | return
43 | }
44 | getNavigationController().updateAppBar(hidden = true)
45 | view.findViewById(R.id.photo).setImageResource(photoResId)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/example/android/bubbles/MainActivityTest.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles
17 |
18 | import androidx.test.core.app.ActivityScenario
19 | import androidx.test.espresso.Espresso.onView
20 | import androidx.test.espresso.action.ViewActions.click
21 | import androidx.test.espresso.assertion.ViewAssertions.matches
22 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
23 | import androidx.test.espresso.matcher.ViewMatchers.withHint
24 | import androidx.test.espresso.matcher.ViewMatchers.withText
25 | import androidx.test.ext.junit.runners.AndroidJUnit4
26 | import org.junit.Test
27 | import org.junit.runner.RunWith
28 |
29 | @RunWith(AndroidJUnit4::class)
30 | class MainActivityTest {
31 |
32 | @Test
33 | fun navigateToChatFragment() {
34 | ActivityScenario.launch(MainActivity::class.java).use {
35 | onView(withText("Cat"))
36 | .check(matches(isDisplayed()))
37 | .perform(click())
38 | onView(withHint("Type a message…")).check(matches(isDisplayed()))
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/NavigationController.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles
17 |
18 | import android.widget.ImageView
19 | import android.widget.TextView
20 | import androidx.annotation.DrawableRes
21 | import androidx.fragment.app.Fragment
22 |
23 | /**
24 | * Common interface between [MainActivity] and [BubbleActivity].
25 | */
26 | interface NavigationController {
27 |
28 | fun openChat(id: Long)
29 |
30 | fun openPhoto(@DrawableRes photo: Int)
31 |
32 | /**
33 | * Updates the appearance and functionality of the app bar.
34 | *
35 | * @param showContact Whether to show contact information instead the screen title.
36 | * @param hidden Whether to hide the app bar.
37 | * @param body Provide this function to update the content of the app bar.
38 | */
39 | fun updateAppBar(
40 | showContact: Boolean = true,
41 | hidden: Boolean = false,
42 | body: (name: TextView, icon: ImageView) -> Unit = { _, _ -> }
43 | )
44 | }
45 |
46 | fun Fragment.getNavigationController() = requireActivity() as NavigationController
47 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/example/android/bubbles/BubbleActivityTest.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles
17 |
18 | import android.app.Application
19 | import android.content.Intent
20 | import android.net.Uri
21 | import androidx.test.core.app.ActivityScenario
22 | import androidx.test.core.app.ApplicationProvider
23 | import androidx.test.espresso.Espresso.onView
24 | import androidx.test.espresso.assertion.ViewAssertions.matches
25 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
26 | import androidx.test.espresso.matcher.ViewMatchers.withHint
27 | import androidx.test.ext.junit.runners.AndroidJUnit4
28 | import org.junit.Test
29 | import org.junit.runner.RunWith
30 |
31 | @RunWith(AndroidJUnit4::class)
32 | class BubbleActivityTest {
33 |
34 | @Test
35 | fun showsChatFragment() {
36 | ActivityScenario.launch(
37 | Intent(ApplicationProvider.getApplicationContext(), BubbleActivity::class.java)
38 | .setAction(Intent.ACTION_VIEW)
39 | .setData(Uri.parse("https://android.example.com/chat/1"))
40 | ).use {
41 | onView(withHint("Type a message…")).check(matches(isDisplayed()))
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/VoiceCallActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles
17 |
18 | import android.os.Bundle
19 | import android.widget.ImageView
20 | import android.widget.TextView
21 | import androidx.appcompat.app.AppCompatActivity
22 | import com.bumptech.glide.Glide
23 | import com.bumptech.glide.request.RequestOptions
24 |
25 | /**
26 | * A dummy voice call screen. It only shows the icon and the name.
27 | */
28 | class VoiceCallActivity : AppCompatActivity() {
29 |
30 | companion object {
31 | const val EXTRA_NAME = "name"
32 | const val EXTRA_ICON = "icon"
33 | }
34 |
35 | override fun onCreate(savedInstanceState: Bundle?) {
36 | super.onCreate(savedInstanceState)
37 | setContentView(R.layout.voice_call_activity)
38 | val name = intent.getStringExtra(EXTRA_NAME)
39 | val icon = intent.getIntExtra(EXTRA_ICON, 0)
40 | if (name == null || icon == 0) {
41 | finish()
42 | return
43 | }
44 | val textName: TextView = findViewById(R.id.name)
45 | textName.text = name
46 | val imageIcon: ImageView = findViewById(R.id.icon)
47 | Glide.with(imageIcon).load(icon).apply(RequestOptions.circleCropTransform()).into(imageIcon)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 | apply plugin: 'kotlin-android'
3 |
4 | android {
5 | compileSdkVersion 'android-Q'
6 | defaultConfig {
7 | applicationId "com.example.android.bubbles"
8 | minSdkVersion 'Q'
9 | targetSdkVersion 'Q'
10 | versionCode 1
11 | versionName '1.0'
12 | testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
13 | }
14 | buildTypes {
15 | release {
16 | minifyEnabled false
17 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
18 | }
19 | }
20 | }
21 |
22 | dependencies {
23 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
24 |
25 | implementation 'androidx.appcompat:appcompat:1.0.2'
26 | implementation 'androidx.fragment:fragment-ktx:1.0.0'
27 | implementation 'androidx.core:core-ktx:1.0.1'
28 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
29 | implementation 'androidx.recyclerview:recyclerview:1.0.0'
30 |
31 | def lifecycle_version = '2.0.0'
32 | implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
33 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
34 | testImplementation "androidx.arch.core:core-testing:$lifecycle_version"
35 |
36 | implementation 'com.google.android.material:material:1.0.0'
37 |
38 | implementation 'com.github.bumptech.glide:glide:4.9.0'
39 |
40 | testImplementation 'junit:junit:4.12'
41 | androidTestImplementation 'androidx.test.ext:junit:1.1.0'
42 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
43 |
44 | testImplementation 'org.robolectric:robolectric:4.2'
45 | testImplementation "androidx.arch.core:core-testing:$lifecycle_version"
46 | testImplementation 'androidx.test.ext:junit:1.1.0'
47 | testImplementation 'androidx.test.espresso:espresso-core:3.1.1'
48 | testImplementation 'androidx.test.ext:truth:1.1.0'
49 | testImplementation 'com.google.truth:truth:0.42'
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/chat_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
29 |
30 |
38 |
39 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/app/src/test/java/com/example/android/bubbles/ui/main/MainViewModelTest.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.main
17 |
18 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
19 | import androidx.test.core.app.ApplicationProvider
20 | import androidx.test.ext.junit.runners.AndroidJUnit4
21 | import androidx.test.filters.SmallTest
22 | import androidx.test.platform.app.InstrumentationRegistry
23 | import com.example.android.bubbles.data.Chat
24 | import com.example.android.bubbles.data.Contact
25 | import com.example.android.bubbles.data.TestChatRepository
26 | import com.google.common.truth.Truth.assertThat
27 | import org.junit.Before
28 | import org.junit.Rule
29 | import org.junit.Test
30 | import org.junit.runner.RunWith
31 |
32 | @RunWith(AndroidJUnit4::class)
33 | @SmallTest
34 | class MainViewModelTest {
35 |
36 | @get:Rule
37 | val instantTaskExecutorRule = InstantTaskExecutorRule()
38 |
39 | private val dummyContacts = Contact.CONTACTS
40 |
41 | private fun createViewModel(): MainViewModel {
42 | var viewModel: MainViewModel? = null
43 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
44 | viewModel = MainViewModel(
45 | ApplicationProvider.getApplicationContext(),
46 | TestChatRepository(dummyContacts.map { contact ->
47 | contact.id to Chat(contact)
48 | }.toMap())
49 | )
50 | }
51 | return viewModel!!
52 | }
53 |
54 | @Test
55 | fun hasListOfContacts() {
56 | val viewModel = createViewModel()
57 | val contacts = viewModel.contacts.value
58 | assertThat(contacts).isEqualTo(dummyContacts)
59 | }
60 |
61 | }
62 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/BubbleActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles
17 |
18 | import android.os.Bundle
19 | import android.widget.ImageView
20 | import android.widget.TextView
21 | import androidx.appcompat.app.AppCompatActivity
22 | import androidx.fragment.app.transaction
23 | import com.example.android.bubbles.ui.chat.ChatFragment
24 | import com.example.android.bubbles.ui.photo.PhotoFragment
25 |
26 | /**
27 | * Entry point of the app when it is launched as an expanded Bubble.
28 | */
29 | class BubbleActivity : AppCompatActivity(), NavigationController {
30 |
31 | override fun onCreate(savedInstanceState: Bundle?) {
32 | super.onCreate(savedInstanceState)
33 | setContentView(R.layout.bubble_activity)
34 | val id = intent.data.lastPathSegment.toLongOrNull() ?: return
35 | if (savedInstanceState == null) {
36 | supportFragmentManager.transaction(now = true) {
37 | replace(R.id.container, ChatFragment.newInstance(id, false))
38 | }
39 | }
40 | }
41 |
42 | override fun openChat(id: Long) {
43 | throw UnsupportedOperationException("BubbleActivity always shows a single chat thread.")
44 | }
45 |
46 | override fun openPhoto(photo: Int) {
47 | // In an expanded Bubble, you can navigate between Fragments just like you would normally do in a normal
48 | // Activity. Just make sure you don't block onBackPressed().
49 | supportFragmentManager.transaction {
50 | addToBackStack(null)
51 | replace(R.id.container, PhotoFragment.newInstance(photo))
52 | }
53 | }
54 |
55 | override fun updateAppBar(showContact: Boolean, hidden: Boolean, body: (name: TextView, icon: ImageView) -> Unit) {
56 | // The expanded bubble does not have an app bar. Ignore.
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/voice_call_activity.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
25 |
26 |
30 |
31 |
39 |
40 |
48 |
49 |
57 |
58 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS="-Xmx64m"
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/ui/main/MainFragment.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.main
17 |
18 | import android.os.Bundle
19 | import android.transition.TransitionInflater
20 | import android.view.LayoutInflater
21 | import android.view.View
22 | import android.view.ViewGroup
23 | import androidx.fragment.app.Fragment
24 | import androidx.lifecycle.Observer
25 | import androidx.lifecycle.ViewModelProviders
26 | import androidx.recyclerview.widget.LinearLayoutManager
27 | import androidx.recyclerview.widget.RecyclerView
28 | import com.example.android.bubbles.R
29 | import com.example.android.bubbles.getNavigationController
30 |
31 | /**
32 | * The main chat list screen.
33 | */
34 | class MainFragment : Fragment() {
35 |
36 | override fun onCreate(savedInstanceState: Bundle?) {
37 | super.onCreate(savedInstanceState)
38 | exitTransition = TransitionInflater.from(context).inflateTransition(R.transition.slide_top)
39 | }
40 |
41 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
42 | return inflater.inflate(R.layout.main_fragment, container, false)
43 | }
44 |
45 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
46 | val navigationController = getNavigationController()
47 | navigationController.updateAppBar(false)
48 | val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
49 |
50 | val contactAdapter = ContactAdapter { id ->
51 | navigationController.openChat(id)
52 | }
53 | viewModel.contacts.observe(viewLifecycleOwner, Observer { contacts ->
54 | contactAdapter.submitList(contacts)
55 | })
56 |
57 | view.findViewById(R.id.contacts).run {
58 | layoutManager = LinearLayoutManager(view.context)
59 | setHasFixedSize(true)
60 | adapter = contactAdapter
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/data/Contact.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.data
17 |
18 | import androidx.annotation.DrawableRes
19 | import com.example.android.bubbles.R
20 |
21 | abstract class Contact(
22 | val id: Long,
23 | val name: String,
24 | @DrawableRes
25 | val icon: Int
26 | ) {
27 |
28 | companion object {
29 | val CONTACTS = listOf(
30 | object : Contact(1L, "Cat", R.drawable.cat) {
31 | override fun reply(text: String) = buildReply().apply { this.text = "Meow" }
32 | },
33 | object : Contact(2L, "Dog", R.drawable.dog) {
34 | override fun reply(text: String) = buildReply().apply { this.text = "Woof woof!!" }
35 | },
36 | object : Contact(3L, "Parrot", R.drawable.parrot) {
37 | override fun reply(text: String) = buildReply().apply { this.text = text }
38 | },
39 | object : Contact(4L, "Sheep", R.drawable.sheep) {
40 | override fun reply(text: String) = buildReply().apply {
41 | this.text = "Look at me!"
42 | photo = R.drawable.sheep_full
43 | }
44 | }
45 | )
46 | }
47 |
48 | fun buildReply() = Message.Builder().apply {
49 | sender = this@Contact.id
50 | timestamp = System.currentTimeMillis()
51 | }
52 |
53 | abstract fun reply(text: String): Message.Builder
54 |
55 | override fun equals(other: Any?): Boolean {
56 | if (this === other) return true
57 | if (javaClass != other?.javaClass) return false
58 |
59 | other as Contact
60 |
61 | if (id != other.id) return false
62 | if (name != other.name) return false
63 | if (icon != other.icon) return false
64 |
65 | return true
66 | }
67 |
68 | override fun hashCode(): Int {
69 | var result = id.hashCode()
70 | result = 31 * result + name.hashCode()
71 | result = 31 * result + icon
72 | return result
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/ui/main/ContactAdapter.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.main
17 |
18 | import android.view.LayoutInflater
19 | import android.view.ViewGroup
20 | import android.widget.ImageView
21 | import android.widget.TextView
22 | import androidx.recyclerview.widget.DiffUtil
23 | import androidx.recyclerview.widget.ListAdapter
24 | import androidx.recyclerview.widget.RecyclerView
25 | import com.bumptech.glide.Glide
26 | import com.bumptech.glide.request.RequestOptions
27 | import com.example.android.bubbles.R
28 | import com.example.android.bubbles.data.Contact
29 |
30 | class ContactAdapter(
31 | private val onChatClicked: (id: Long) -> Unit
32 | ) : ListAdapter(DIFF_CALLBACK) {
33 |
34 | init {
35 | setHasStableIds(true)
36 | }
37 |
38 | override fun getItemId(position: Int): Long {
39 | return getItem(position).id
40 | }
41 |
42 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactViewHolder {
43 | val holder = ContactViewHolder(parent)
44 | holder.itemView.setOnClickListener {
45 | onChatClicked(holder.itemId)
46 | }
47 | return holder
48 | }
49 |
50 | override fun onBindViewHolder(holder: ContactViewHolder, position: Int) {
51 | val contact: Contact = getItem(position)
52 | Glide.with(holder.icon).load(contact.icon).apply(RequestOptions.circleCropTransform()).into(holder.icon)
53 | holder.name.text = contact.name
54 | }
55 | }
56 |
57 | private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
58 | override fun areItemsTheSame(oldItem: Contact, newItem: Contact): Boolean {
59 | return oldItem.id == newItem.id
60 | }
61 |
62 | override fun areContentsTheSame(oldItem: Contact, newItem: Contact): Boolean {
63 | return oldItem == newItem
64 | }
65 | }
66 |
67 | class ContactViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
68 | LayoutInflater.from(parent.context).inflate(R.layout.chat_item, parent, false)
69 | ) {
70 | val icon: ImageView = itemView.findViewById(R.id.icon)
71 | val name: TextView = itemView.findViewById(R.id.name)
72 | }
73 |
--------------------------------------------------------------------------------
/app/src/test/java/com/example/android/bubbles/data/TestChatRepository.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.data
17 |
18 | import androidx.lifecycle.LiveData
19 | import androidx.lifecycle.MutableLiveData
20 |
21 | /**
22 | * This is like [DefaultChatRepository] except:
23 | * - The initial chat history can be supplied as a constructor parameter.
24 | * - It does not wait 5 seconds to receive a reply.
25 | */
26 | class TestChatRepository(private val chats: Map) : ChatRepository {
27 |
28 | var activatedId = 0L
29 |
30 | var bubbleId = 0L
31 |
32 | override fun getContacts(): LiveData> {
33 | return MutableLiveData>().apply {
34 | value = chats.values.map { it.contact }
35 | }
36 | }
37 |
38 | override fun findContact(id: Long): LiveData {
39 | return MutableLiveData().apply {
40 | value = Contact.CONTACTS.find { it.id == id }
41 | }
42 | }
43 |
44 | override fun findMessages(id: Long): LiveData> {
45 | val chat = chats.getValue(id)
46 | return object : LiveData>() {
47 |
48 | private val listener = { messages: List ->
49 | postValue(messages)
50 | }
51 |
52 | override fun onActive() {
53 | value = chat.messages
54 | chat.addListener(listener)
55 | }
56 |
57 | override fun onInactive() {
58 | chat.removeListener(listener)
59 | }
60 | }
61 | }
62 |
63 | override fun sendMessage(id: Long, text: String) {
64 | val chat = chats.getValue(id)
65 | chat.addMessage(Message.Builder().apply {
66 | sender = 0L // User
67 | this.text = text
68 | timestamp = System.currentTimeMillis()
69 | })
70 | chat.addMessage(chat.contact.reply(text))
71 | }
72 |
73 | override fun activateChat(id: Long) {
74 | activatedId = id
75 | }
76 |
77 | override fun deactivateChat(id: Long) {
78 | activatedId = 0L
79 | }
80 |
81 | override fun showAsBubble(id: Long) {
82 | bubbleId = id
83 | }
84 |
85 | override fun canBubble(): Boolean {
86 | return true
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/chat_fragment.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
24 |
25 |
35 |
36 |
43 |
44 |
52 |
53 |
62 |
63 |
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/app/src/test/java/com/example/android/bubbles/ui/chat/ChatViewModelTest.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.chat
17 |
18 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
19 | import androidx.test.core.app.ApplicationProvider
20 | import androidx.test.ext.junit.runners.AndroidJUnit4
21 | import androidx.test.filters.SmallTest
22 | import androidx.test.platform.app.InstrumentationRegistry
23 | import com.example.android.bubbles.data.Chat
24 | import com.example.android.bubbles.data.Contact
25 | import com.example.android.bubbles.data.TestChatRepository
26 | import com.example.android.bubbles.observedValue
27 | import com.google.common.truth.Truth.assertThat
28 | import org.junit.Before
29 | import org.junit.Rule
30 | import org.junit.Test
31 | import org.junit.runner.RunWith
32 |
33 | @RunWith(AndroidJUnit4::class)
34 | @SmallTest
35 | class ChatViewModelTest {
36 |
37 | @get:Rule
38 | val instantTaskExecutorRule = InstantTaskExecutorRule()
39 |
40 | private val dummyContacts = Contact.CONTACTS
41 |
42 | private lateinit var viewModel: ChatViewModel
43 | private lateinit var repository: TestChatRepository
44 |
45 | @Before
46 | fun createViewModel() {
47 | repository = TestChatRepository(dummyContacts.map { contact ->
48 | contact.id to Chat(contact)
49 | }.toMap())
50 | InstrumentationRegistry.getInstrumentation().runOnMainSync {
51 | viewModel = ChatViewModel(ApplicationProvider.getApplicationContext(), repository)
52 | }
53 | }
54 |
55 | @Test
56 | fun hasContactAndMessages() {
57 | viewModel.setChatId(1L)
58 | viewModel.foreground = true
59 | assertThat(viewModel.contact.observedValue()).isEqualTo(dummyContacts.find { it.id == 1L })
60 | assertThat(viewModel.messages.observedValue()).hasSize(2)
61 | assertThat(repository.activatedId).isEqualTo(1L)
62 | }
63 |
64 | @Test
65 | fun sendAndReceiveReply() {
66 | viewModel.setChatId(1L)
67 | viewModel.send("a")
68 | val messages = viewModel.messages.observedValue()
69 | assertThat(messages).hasSize(4)
70 | assertThat(messages[2].text).isEqualTo("a")
71 | assertThat(messages[3].text).isEqualTo("Meow")
72 | }
73 |
74 | @Test
75 | fun showAsBubble() {
76 | viewModel.setChatId(1L)
77 | assertThat(repository.bubbleId).isEqualTo(0L)
78 | viewModel.showAsBubble()
79 | assertThat(repository.bubbleId).isEqualTo(1L)
80 | }
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/main_activity.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
26 |
27 |
34 |
35 |
46 |
47 |
60 |
61 |
68 |
69 |
70 |
71 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/ui/chat/ChatViewModel.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.chat
17 |
18 | import android.app.Application
19 | import androidx.lifecycle.AndroidViewModel
20 | import androidx.lifecycle.LiveData
21 | import androidx.lifecycle.MutableLiveData
22 | import androidx.lifecycle.Transformations
23 | import com.example.android.bubbles.data.ChatRepository
24 | import com.example.android.bubbles.data.Contact
25 | import com.example.android.bubbles.data.DefaultChatRepository
26 | import com.example.android.bubbles.data.Message
27 |
28 | class ChatViewModel @JvmOverloads constructor(
29 | application: Application,
30 | private val repository: ChatRepository = DefaultChatRepository.getInstance(application)
31 | ) : AndroidViewModel(application) {
32 |
33 | private val chatId = MutableLiveData()
34 |
35 | /**
36 | * We want to dismiss a notification when the corresponding chat screen is open. Setting this to `true` dismisses
37 | * the current notification and suppresses further notifications.
38 | *
39 | * We do want to keep on showing and updating the notification when the chat screen is opened as an expanded bubble.
40 | * [ChatFragment] should set this to false if it is launched in BubbleActivity. Otherwise, the expanding a bubble
41 | * would remove the notification and the bubble.
42 | */
43 | var foreground = false
44 | set(value) {
45 | field = value
46 | chatId.value?.let { id ->
47 | if (value) {
48 | repository.activateChat(id)
49 | } else {
50 | repository.deactivateChat(id)
51 | }
52 | }
53 | }
54 |
55 | /**
56 | * The contact of this chat.
57 | */
58 | val contact: LiveData = Transformations.switchMap(chatId) { id ->
59 | repository.findContact(id)
60 | }
61 |
62 | /**
63 | * The list of all the messages in this chat.
64 | */
65 | val messages: LiveData> = Transformations.switchMap(chatId) { id ->
66 | repository.findMessages(id)
67 | }
68 |
69 | /**
70 | * Whether the "Show as Bubble" button should be shown.
71 | */
72 | val showAsBubbleVisible: LiveData = object: LiveData() {
73 | override fun onActive() {
74 | // We hide the "Show as Bubble" button if we are not allowed to show the bubble.
75 | value = repository.canBubble()
76 | }
77 | }
78 |
79 | fun setChatId(id: Long) {
80 | chatId.value = id
81 | if (foreground) {
82 | repository.activateChat(id)
83 | } else {
84 | repository.deactivateChat(id)
85 | }
86 | }
87 |
88 | fun send(text: String) {
89 | val id = chatId.value
90 | if (id != null && id != 0L) {
91 | repository.sendMessage(id, text)
92 | }
93 | }
94 |
95 | fun showAsBubble() {
96 | chatId.value?.let { id ->
97 | repository.showAsBubble(id)
98 | }
99 | }
100 |
101 | override fun onCleared() {
102 | chatId.value?.let { id -> repository.deactivateChat(id) }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
17 |
21 |
22 |
30 |
31 |
34 |
37 |
40 |
41 |
42 |
43 |
44 |
47 |
48 |
49 |
50 |
54 |
55 |
56 |
57 |
62 |
66 |
67 |
74 |
79 |
80 |
81 |
82 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/MainActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles
17 |
18 | import android.content.Intent
19 | import android.os.Bundle
20 | import android.transition.Transition
21 | import android.transition.TransitionInflater
22 | import android.transition.TransitionManager
23 | import android.view.View
24 | import android.view.ViewGroup
25 | import android.widget.ImageView
26 | import android.widget.TextView
27 | import androidx.appcompat.app.AppCompatActivity
28 | import androidx.fragment.app.FragmentManager
29 | import androidx.fragment.app.transaction
30 | import com.example.android.bubbles.ui.chat.ChatFragment
31 | import com.example.android.bubbles.ui.main.MainFragment
32 | import com.example.android.bubbles.ui.photo.PhotoFragment
33 |
34 | /**
35 | * Entry point of the app when it is launched as a full app.
36 | */
37 | class MainActivity : AppCompatActivity(), NavigationController {
38 |
39 | companion object {
40 | private const val FRAGMENT_CHAT = "chat"
41 | }
42 |
43 | private lateinit var appBar: ViewGroup
44 | private lateinit var name: TextView
45 | private lateinit var icon: ImageView
46 |
47 | private lateinit var transition: Transition
48 |
49 | override fun onCreate(savedInstanceState: Bundle?) {
50 | super.onCreate(savedInstanceState)
51 | setContentView(R.layout.main_activity)
52 | setSupportActionBar(findViewById(R.id.toolbar))
53 | transition = TransitionInflater.from(this).inflateTransition(R.transition.app_bar)
54 | appBar = findViewById(R.id.app_bar)
55 | name = findViewById(R.id.name)
56 | icon = findViewById(R.id.icon)
57 | if (savedInstanceState == null) {
58 | supportFragmentManager.transaction(now = true) {
59 | replace(R.id.container, MainFragment())
60 | }
61 | intent?.let(::handleIntent)
62 | }
63 | }
64 |
65 | override fun onNewIntent(intent: Intent?) {
66 | super.onNewIntent(intent)
67 | if (intent != null) {
68 | handleIntent(intent)
69 | }
70 | }
71 |
72 | private fun handleIntent(intent: Intent) {
73 | if (intent.action == Intent.ACTION_VIEW) {
74 | val id = intent.data.lastPathSegment.toLongOrNull()
75 | if (id != null) {
76 | openChat(id)
77 | }
78 | }
79 | }
80 |
81 | override fun updateAppBar(showContact: Boolean, hidden: Boolean, body: (name: TextView, icon: ImageView) -> Unit) {
82 | if (hidden) {
83 | appBar.visibility = View.GONE
84 | } else {
85 | appBar.visibility = View.VISIBLE
86 | TransitionManager.beginDelayedTransition(appBar, transition)
87 | if (showContact) {
88 | supportActionBar?.setDisplayShowTitleEnabled(false)
89 | name.visibility = View.VISIBLE
90 | icon.visibility = View.VISIBLE
91 | } else {
92 | supportActionBar?.setDisplayShowTitleEnabled(true)
93 | name.visibility = View.GONE
94 | icon.visibility = View.GONE
95 | }
96 | }
97 | body(name, icon)
98 | }
99 |
100 | override fun openChat(id: Long) {
101 | supportFragmentManager.popBackStack(FRAGMENT_CHAT, FragmentManager.POP_BACK_STACK_INCLUSIVE)
102 | supportFragmentManager.transaction {
103 | addToBackStack(FRAGMENT_CHAT)
104 | replace(R.id.container, ChatFragment.newInstance(id, true))
105 | }
106 | }
107 |
108 | override fun openPhoto(photo: Int) {
109 | supportFragmentManager.transaction {
110 | addToBackStack(null)
111 | replace(R.id.container, PhotoFragment.newInstance(photo))
112 | }
113 | }
114 | }
115 |
116 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/data/ChatRepository.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.data
17 |
18 | import android.content.Context
19 | import androidx.annotation.MainThread
20 | import androidx.lifecycle.LiveData
21 | import androidx.lifecycle.MutableLiveData
22 | import java.util.concurrent.Executor
23 | import java.util.concurrent.Executors
24 |
25 | interface ChatRepository {
26 | fun getContacts(): LiveData>
27 | fun findContact(id: Long): LiveData
28 | fun findMessages(id: Long): LiveData>
29 | fun sendMessage(id: Long, text: String)
30 | fun activateChat(id: Long)
31 | fun deactivateChat(id: Long)
32 | fun showAsBubble(id: Long)
33 | fun canBubble(): Boolean
34 | }
35 |
36 | class DefaultChatRepository internal constructor(
37 | private val notificationHelper: NotificationHelper,
38 | private val executor: Executor
39 | ) : ChatRepository {
40 |
41 | companion object {
42 | private var instance: DefaultChatRepository? = null
43 |
44 | fun getInstance(context: Context): DefaultChatRepository {
45 | return instance ?: synchronized(this) {
46 | instance ?: DefaultChatRepository(
47 | NotificationHelper(context),
48 | Executors.newFixedThreadPool(4)
49 | ).also {
50 | instance = it
51 | }
52 | }
53 | }
54 | }
55 |
56 | private var currentChat: Long = 0L
57 |
58 | private val chats = Contact.CONTACTS.map { contact ->
59 | contact.id to Chat(contact)
60 | }.toMap()
61 |
62 | init {
63 | notificationHelper.setUpNotificationChannels()
64 | }
65 |
66 | @MainThread
67 | override fun getContacts(): LiveData> {
68 | return MutableLiveData>().apply {
69 | postValue(Contact.CONTACTS)
70 | }
71 | }
72 |
73 | @MainThread
74 | override fun findContact(id: Long): LiveData {
75 | return MutableLiveData().apply {
76 | postValue(Contact.CONTACTS.find { it.id == id })
77 | }
78 | }
79 |
80 | @MainThread
81 | override fun findMessages(id: Long): LiveData> {
82 | val chat = chats.getValue(id)
83 | return object : LiveData>() {
84 |
85 | private val listener = { messages: List ->
86 | postValue(messages)
87 | }
88 |
89 | override fun onActive() {
90 | value = chat.messages
91 | chat.addListener(listener)
92 | }
93 |
94 | override fun onInactive() {
95 | chat.removeListener(listener)
96 | }
97 | }
98 | }
99 |
100 | @MainThread
101 | override fun sendMessage(id: Long, text: String) {
102 | val chat = chats.getValue(id)
103 | chat.addMessage(Message.Builder().apply {
104 | sender = 0L // User
105 | this.text = text
106 | timestamp = System.currentTimeMillis()
107 | })
108 | executor.execute {
109 | // The animal is typing...
110 | Thread.sleep(5000L)
111 | // Receive a reply.
112 | chat.addMessage(chat.contact.reply(text))
113 | // Show notification if the chat is not on the foreground.
114 | if (chat.contact.id != currentChat) {
115 | notificationHelper.showNotification(chat, false)
116 | }
117 | }
118 | }
119 |
120 | override fun activateChat(id: Long) {
121 | currentChat = id
122 | notificationHelper.dismissNotification(id)
123 | }
124 |
125 | override fun deactivateChat(id: Long) {
126 | if (currentChat == id) {
127 | currentChat = 0
128 | }
129 | }
130 |
131 | override fun showAsBubble(id: Long) {
132 | val chat = chats.getValue(id)
133 | executor.execute {
134 | notificationHelper.showNotification(chat, true)
135 | }
136 | }
137 |
138 | override fun canBubble(): Boolean {
139 | return notificationHelper.canBubble()
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/ui/chat/MessageAdapter.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.chat
17 |
18 | import android.content.Context
19 | import android.content.res.ColorStateList
20 | import android.view.Gravity
21 | import android.view.LayoutInflater
22 | import android.view.ViewGroup
23 | import android.widget.FrameLayout
24 | import android.widget.TextView
25 | import androidx.core.content.ContextCompat
26 | import androidx.core.view.ViewCompat
27 | import androidx.recyclerview.widget.DiffUtil
28 | import androidx.recyclerview.widget.ListAdapter
29 | import androidx.recyclerview.widget.RecyclerView
30 | import com.example.android.bubbles.R
31 | import com.example.android.bubbles.data.Message
32 |
33 | class MessageAdapter(
34 | context: Context,
35 | private val onPhotoClicked: (photo: Int) -> Unit
36 | ) : ListAdapter(DIFF_CALLBACK) {
37 |
38 | private val tint = object {
39 | val incoming: ColorStateList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.incoming))
40 | val outgoing: ColorStateList = ColorStateList.valueOf(
41 | ContextCompat.getColor(context, R.color.outgoing)
42 | )
43 | }
44 |
45 | private val padding = object {
46 | val vertical: Int = context.resources.getDimensionPixelSize(R.dimen.message_padding_vertical)
47 |
48 | val horizontalShort: Int = context.resources.getDimensionPixelSize(
49 | R.dimen.message_padding_horizontal_short
50 | )
51 |
52 | val horizontalLong: Int = context.resources.getDimensionPixelSize(
53 | R.dimen.message_padding_horizontal_long
54 | )
55 | }
56 |
57 |
58 | init {
59 | setHasStableIds(true)
60 | }
61 |
62 | override fun getItemId(position: Int): Long {
63 | return getItem(position).id
64 | }
65 |
66 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
67 | val holder = MessageViewHolder(parent)
68 | holder.message.setOnClickListener {
69 | val photo: Int? = it.getTag(R.id.tag_photo) as Int?
70 | if (photo != null) {
71 | onPhotoClicked(photo)
72 | }
73 | }
74 | return holder
75 | }
76 |
77 | override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
78 | val message = getItem(position)
79 | val lp = holder.message.layoutParams as FrameLayout.LayoutParams
80 | if (message.isIncoming) {
81 | holder.message.run {
82 | setBackgroundResource(R.drawable.message_incoming)
83 | ViewCompat.setBackgroundTintList(this, tint.incoming)
84 | setPadding(
85 | padding.horizontalLong, padding.vertical,
86 | padding.horizontalShort, padding.vertical
87 | )
88 | layoutParams = lp.apply {
89 | gravity = Gravity.START
90 | }
91 | if (message.photo != null) {
92 | holder.message.setTag(R.id.tag_photo, message.photo)
93 | setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, message.photo)
94 | } else {
95 | holder.message.setTag(R.id.tag_photo, null)
96 | setCompoundDrawables(null, null, null, null)
97 | }
98 | }
99 | } else {
100 | holder.message.run {
101 | setBackgroundResource(R.drawable.message_outgoing)
102 | ViewCompat.setBackgroundTintList(this, tint.outgoing)
103 | setPadding(
104 | padding.horizontalShort, padding.vertical,
105 | padding.horizontalLong, padding.vertical
106 | )
107 | layoutParams = lp.apply {
108 | gravity = Gravity.END
109 | }
110 | }
111 | }
112 | holder.message.text = message.text
113 | }
114 | }
115 |
116 | private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
117 |
118 | override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean {
119 | return oldItem.id == newItem.id
120 | }
121 |
122 | override fun areContentsTheSame(oldItem: Message, newItem: Message): Boolean {
123 | return oldItem == newItem
124 | }
125 |
126 | }
127 |
128 | class MessageViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
129 | LayoutInflater.from(parent.context).inflate(R.layout.message_item, parent, false)
130 | ) {
131 | val message: TextView = itemView.findViewById(R.id.message)
132 | }
133 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS='"-Xmx64m"'
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/ui/chat/ChatFragment.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.ui.chat
17 |
18 | import android.content.Intent
19 | import android.graphics.drawable.Drawable
20 | import android.os.Bundle
21 | import android.transition.TransitionInflater
22 | import android.view.LayoutInflater
23 | import android.view.Menu
24 | import android.view.MenuInflater
25 | import android.view.MenuItem
26 | import android.view.View
27 | import android.view.ViewGroup
28 | import android.view.inputmethod.EditorInfo
29 | import android.widget.EditText
30 | import android.widget.ImageButton
31 | import android.widget.Toast
32 | import androidx.fragment.app.Fragment
33 | import androidx.lifecycle.Observer
34 | import androidx.lifecycle.ViewModelProviders
35 | import androidx.recyclerview.widget.LinearLayoutManager
36 | import androidx.recyclerview.widget.RecyclerView
37 | import com.bumptech.glide.Glide
38 | import com.bumptech.glide.load.DataSource
39 | import com.bumptech.glide.load.engine.GlideException
40 | import com.bumptech.glide.request.RequestListener
41 | import com.bumptech.glide.request.RequestOptions
42 | import com.bumptech.glide.request.target.Target
43 | import com.example.android.bubbles.R
44 | import com.example.android.bubbles.VoiceCallActivity
45 | import com.example.android.bubbles.getNavigationController
46 |
47 | /**
48 | * The chat screen. This is used in the full app (MainActivity) as well as in the expanded Bubble (BubbleActivity).
49 | */
50 | class ChatFragment : Fragment() {
51 |
52 | companion object {
53 | private const val ARG_ID = "id"
54 | private const val ARG_FOREGROUND = "foreground"
55 |
56 | fun newInstance(id: Long, foreground: Boolean) = ChatFragment().apply {
57 | arguments = Bundle().apply {
58 | putLong(ARG_ID, id)
59 | putBoolean(ARG_FOREGROUND, foreground)
60 | }
61 | }
62 | }
63 |
64 | private lateinit var viewModel: ChatViewModel
65 | private lateinit var input: EditText
66 |
67 | override fun onCreate(savedInstanceState: Bundle?) {
68 | super.onCreate(savedInstanceState)
69 | setHasOptionsMenu(true)
70 | enterTransition = TransitionInflater.from(context).inflateTransition(R.transition.slide_bottom)
71 | }
72 |
73 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
74 | return inflater.inflate(R.layout.chat_fragment, container, false)
75 | }
76 |
77 | private val startPostponedTransitionOnEnd = object : RequestListener {
78 | override fun onLoadFailed(
79 | e: GlideException?,
80 | model: Any?,
81 | target: Target?,
82 | isFirstResource: Boolean
83 | ): Boolean {
84 | startPostponedEnterTransition()
85 | return false
86 | }
87 |
88 | override fun onResourceReady(
89 | resource: Drawable?,
90 | model: Any?,
91 | target: Target?,
92 | dataSource: DataSource?,
93 | isFirstResource: Boolean
94 | ): Boolean {
95 | startPostponedEnterTransition()
96 | return false
97 | }
98 | }
99 |
100 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
101 | val id = arguments?.getLong(ARG_ID)
102 | if (id == null) {
103 | fragmentManager?.popBackStack()
104 | return
105 | }
106 | val navigationController = getNavigationController()
107 |
108 | viewModel = ViewModelProviders.of(this).get(ChatViewModel::class.java)
109 | viewModel.setChatId(id)
110 |
111 | val messages: RecyclerView = view.findViewById(R.id.messages)
112 | val voiceCall: ImageButton = view.findViewById(R.id.voice_call)
113 | input = view.findViewById(R.id.input)
114 | val send: ImageButton = view.findViewById(R.id.send)
115 |
116 | val messageAdapter = MessageAdapter(view.context) { photo ->
117 | navigationController.openPhoto(photo)
118 | }
119 | val linearLayoutManager = LinearLayoutManager(view.context).apply {
120 | stackFromEnd = true
121 | }
122 | messages.run {
123 | layoutManager = linearLayoutManager
124 | adapter = messageAdapter
125 | }
126 |
127 | viewModel.contact.observe(viewLifecycleOwner, Observer { chat ->
128 | if (chat == null) {
129 | Toast.makeText(view.context, "Contact not found", Toast.LENGTH_SHORT).show()
130 | fragmentManager?.popBackStack()
131 | } else {
132 | navigationController.updateAppBar { name, icon ->
133 | name.text = chat.name
134 | Glide.with(icon)
135 | .load(chat.icon)
136 | .apply(RequestOptions.circleCropTransform())
137 | .dontAnimate()
138 | .addListener(startPostponedTransitionOnEnd)
139 | .into(icon)
140 | }
141 | }
142 | })
143 |
144 | viewModel.messages.observe(viewLifecycleOwner, Observer {
145 | messageAdapter.submitList(it)
146 | linearLayoutManager.scrollToPosition(it.size - 1)
147 | })
148 |
149 | voiceCall.setOnClickListener {
150 | voiceCall()
151 | }
152 | send.setOnClickListener {
153 | send()
154 | }
155 | input.setOnEditorActionListener { _, actionId, _ ->
156 | if (actionId == EditorInfo.IME_ACTION_SEND) {
157 | send()
158 | true
159 | } else {
160 | false
161 | }
162 | }
163 | }
164 |
165 | override fun onStart() {
166 | super.onStart()
167 | val foreground = arguments?.getBoolean(ARG_FOREGROUND) == true
168 | viewModel.foreground = foreground
169 | }
170 |
171 | override fun onStop() {
172 | super.onStop()
173 | viewModel.foreground = false
174 | }
175 |
176 | private fun voiceCall() {
177 | val contact = viewModel.contact.value ?: return
178 | startActivity(
179 | Intent(requireActivity(), VoiceCallActivity::class.java)
180 | .putExtra(VoiceCallActivity.EXTRA_NAME, contact.name)
181 | .putExtra(VoiceCallActivity.EXTRA_ICON, contact.icon)
182 | )
183 | }
184 |
185 | private fun send() {
186 | val text = input.text.toString()
187 | if (text.isNotEmpty()) {
188 | input.text.clear()
189 | viewModel.send(text)
190 | }
191 | }
192 |
193 | override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
194 | inflater?.inflate(R.menu.chat, menu)
195 | menu?.findItem(R.id.action_show_as_bubble)?.let { item ->
196 | viewModel.showAsBubbleVisible.observe(viewLifecycleOwner, Observer {
197 | item.isVisible = it
198 | })
199 | }
200 | super.onCreateOptionsMenu(menu, inflater)
201 | }
202 |
203 | override fun onOptionsItemSelected(item: MenuItem?): Boolean {
204 | return when (item?.itemId) {
205 | R.id.action_show_as_bubble -> {
206 | viewModel.showAsBubble()
207 | fragmentManager?.popBackStack()
208 | true
209 | }
210 | else -> super.onOptionsItemSelected(item)
211 | }
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/app/src/main/java/com/example/android/bubbles/data/NotificationHelper.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2019 The Android Open Source Project
3 | * Licensed under the Apache License, Version 2.0 (the "License");
4 | * you may not use this file except in compliance with the License.
5 | * You may obtain a copy of the License at
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software
10 | * distributed under the License is distributed on an "AS IS" BASIS,
11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | * See the License for the specific language governing permissions and
13 | * limitations under the License.
14 | */
15 |
16 | package com.example.android.bubbles.data
17 |
18 | import android.app.Notification
19 | import android.app.NotificationChannel
20 | import android.app.NotificationManager
21 | import android.app.PendingIntent
22 | import android.app.Person
23 | import android.content.Context
24 | import android.content.Intent
25 | import android.graphics.BitmapFactory
26 | import android.graphics.drawable.Icon
27 | import android.net.Uri
28 | import androidx.annotation.WorkerThread
29 | import com.example.android.bubbles.BubbleActivity
30 | import com.example.android.bubbles.MainActivity
31 | import com.example.android.bubbles.R
32 |
33 | /**
34 | * Handles all operations related to [Notification].
35 | */
36 | class NotificationHelper(private val context: Context) {
37 |
38 | companion object {
39 | /**
40 | * The notification channel for messages. This is used for showing Bubbles.
41 | */
42 | private const val CHANNEL_NEW_MESSAGES = "new_messages"
43 |
44 | private const val REQUEST_CONTENT = 1
45 | private const val REQUEST_BUBBLE = 2
46 | }
47 |
48 | private val notificationManager = context.getSystemService(NotificationManager::class.java)
49 |
50 | fun setUpNotificationChannels() {
51 | if (notificationManager.getNotificationChannel(CHANNEL_NEW_MESSAGES) == null) {
52 | notificationManager.createNotificationChannel(
53 | NotificationChannel(
54 | CHANNEL_NEW_MESSAGES,
55 | context.getString(R.string.channel_new_messages),
56 | // The importance must be IMPORTANCE_HIGH to show Bubbles.
57 | NotificationManager.IMPORTANCE_HIGH
58 | ).apply {
59 | description = context.getString(R.string.channel_new_messages_description)
60 | }
61 | )
62 | }
63 | }
64 |
65 | @WorkerThread
66 | fun showNotification(chat: Chat, fromUser: Boolean) {
67 | val icon = Icon.createWithAdaptiveBitmap(
68 | BitmapFactory.decodeResource(
69 | context.resources,
70 | chat.contact.icon
71 | )
72 | )
73 | val person = Person.Builder()
74 | .setName(chat.contact.name)
75 | .setIcon(icon)
76 | .build()
77 | val contentUri = Uri.parse("https://android.example.com/chat/${chat.contact.id}")
78 | val builder = Notification.Builder(context, CHANNEL_NEW_MESSAGES)
79 | // A notification can be shown as a bubble by calling setBubbleMetadata()
80 | .setBubbleMetadata(
81 | Notification.BubbleMetadata.Builder()
82 | // The height of the expanded bubble.
83 | .setDesiredHeight(context.resources.getDimensionPixelSize(R.dimen.bubble_height))
84 | // The icon of the bubble.
85 | // TODO: The icon is not displayed in Android Q Beta 2.
86 | .setIcon(icon)
87 | .apply {
88 | // When the bubble is explicitly opened by the user, we can show the bubble automatically
89 | // in the expanded state. This works only when the app is in the foreground.
90 | // TODO: This does not yet work in Android Q Beta 2.
91 | if (fromUser) {
92 | setAutoExpandBubble(true)
93 | setSuppressInitialNotification(true)
94 | }
95 | }
96 | // The Intent to be used for the expanded bubble.
97 | .setIntent(
98 | PendingIntent.getActivity(
99 | context,
100 | REQUEST_BUBBLE,
101 | // Launch BubbleActivity as the expanded bubble.
102 | Intent(context, BubbleActivity::class.java)
103 | .setAction(Intent.ACTION_VIEW)
104 | .setData(Uri.parse("https://android.example.com/chat/${chat.contact.id}")),
105 | PendingIntent.FLAG_UPDATE_CURRENT
106 | )
107 | )
108 | .build()
109 | )
110 | // The user can turn off the bubble in system settings. In that case, this notification is shown as a
111 | // normal notification instead of a bubble. Make sure that this notification works as a normal notification
112 | // as well.
113 | .setContentTitle(chat.contact.name)
114 | .setSmallIcon(R.drawable.ic_message)
115 | .setCategory(Notification.CATEGORY_MESSAGE)
116 | .addPerson(person)
117 | .setShowWhen(true)
118 | // The content Intent is used when the user clicks on the "Open Content" icon button on the expanded bubble,
119 | // as well as when the fall-back notification is clicked.
120 | .setContentIntent(
121 | PendingIntent.getActivity(
122 | context,
123 | REQUEST_CONTENT,
124 | Intent(context, MainActivity::class.java)
125 | .setAction(Intent.ACTION_VIEW)
126 | .setData(contentUri),
127 | PendingIntent.FLAG_UPDATE_CURRENT
128 | )
129 | )
130 |
131 | if (fromUser) {
132 | // This is a Bubble explicitly opened by the user.
133 | builder.setContentText(context.getString(R.string.chat_with_contact, chat.contact.name))
134 | } else {
135 | // Let's add some more content to the notification in case it falls back to a normal notification.
136 | val lastOutgoingId = chat.messages.last { !it.isIncoming }.id
137 | val newMessages = chat.messages.filter { message ->
138 | message.id > lastOutgoingId
139 | }
140 | val lastMessage = newMessages.last()
141 | builder
142 | .setStyle(
143 | if (lastMessage.photo != null) {
144 | Notification.BigPictureStyle()
145 | .bigPicture(BitmapFactory.decodeResource(context.resources, lastMessage.photo))
146 | .bigLargeIcon(icon)
147 | .setSummaryText(lastMessage.text)
148 | } else {
149 | Notification.MessagingStyle(person)
150 | .apply {
151 | for (message in newMessages) {
152 | addMessage(message.text, message.timestamp, person)
153 | }
154 | }
155 | .setGroupConversation(false)
156 | }
157 | )
158 | .setContentText(newMessages.joinToString("\n") { it.text })
159 | .setWhen(newMessages.last().timestamp)
160 | }
161 |
162 | notificationManager.notify(chat.contact.id.toInt(), builder.build())
163 | }
164 |
165 | fun dismissNotification(id: Long) {
166 | notificationManager.cancel(id.toInt())
167 | }
168 |
169 | fun canBubble(): Boolean {
170 | val channel = notificationManager.getNotificationChannel(CHANNEL_NEW_MESSAGES)
171 | return notificationManager.areBubblesAllowed() && channel.canBubble()
172 | }
173 | }
174 |
--------------------------------------------------------------------------------