├── 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 | 4 | 5 | 10 | 11 | 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 | --------------------------------------------------------------------------------