Creation of UI to display message and to send messages.

- Recycler view created
 - layout for message created
 - Two way messaging created with random message trigger after message submission
 - Messages are stored in room database and persisted there
This commit is contained in:
2020-09-16 14:34:45 +01:00
parent d6d5475fc4
commit d6652e086e
23 changed files with 544 additions and 43 deletions

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -10,7 +10,7 @@ android {
defaultConfig {
applicationId "com.example.h_mal.messengerapp"
minSdkVersion 23
minSdkVersion 24
targetSdkVersion 30
versionCode 1
versionName "1.0"
@@ -42,6 +42,8 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation "androidx.cardview:cardview:1.0.0"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'

View File

@@ -39,7 +39,7 @@ class AppClass: Application(), KodeinAware{
)
}
bind() from singleton { AppRoomDatabase(instance()) }
bind() from singleton { RepositoryImpl(instance()) }
bind() from singleton { RepositoryImpl(instance(), instance()) }
bind() from provider { MainViewModelFactory(instance()) }
}
}

View File

@@ -1,5 +1,6 @@
package com.example.h_mal.messengerapp.data.network
import com.example.h_mal.messengerapp.data.network.model.MessageItem
import com.tinder.scarlet.Scarlet
import com.tinder.scarlet.Stream
import com.tinder.scarlet.WebSocket
@@ -14,14 +15,16 @@ import okhttp3.OkHttpClient
interface MessengerApi {
// Receive websocket messages in the form of string
@Receive
fun observerMessage(): ReceiveChannel<String>
fun observerMessage(): ReceiveChannel<MessageItem>
@Receive
fun observerFailure(): ReceiveChannel<WebSocket.Event.OnConnectionFailed>
fun observerEvent(): ReceiveChannel<WebSocket.Event>
// Send message to websocket and return pass/fail boolean result
@Send
fun send(message: Any): Boolean
fun send(message: MessageItem): Boolean
// invoke method creating an invocation of the api call
@@ -32,7 +35,7 @@ interface MessengerApi {
val webSocketUrl = "ws://echo.websocket.org/"
// creation of retrofit class
// creation of Api class for websocket
return Scarlet.Builder()
.webSocketFactory(okkHttpClient.newWebSocketFactory(webSocketUrl))
.addMessageAdapterFactory(GsonMessageAdapter.Factory())

View File

@@ -0,0 +1,16 @@
package com.example.h_mal.messengerapp.data.network.model
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
data class MessageItem(
@SerializedName("Message")
@Expose
var message: String? = null,
@SerializedName("IsSender")
@Expose
var isSender: Boolean? = null,
@SerializedName("TimeStamp")
@Expose
var timeStamp: Long = System.currentTimeMillis()
)

View File

@@ -1,11 +1,19 @@
package com.example.h_mal.messengerapp.data.repository
import androidx.lifecycle.LiveData
import com.example.h_mal.messengerapp.data.network.model.MessageItem
import com.example.h_mal.messengerapp.data.room.MessageEntity
import com.tinder.scarlet.WebSocket
import kotlinx.coroutines.channels.ReceiveChannel
interface Repository {
fun observeText(): ReceiveChannel<String>
fun submitMessage(message: String)
fun getEvent(): ReceiveChannel<WebSocket.Event>
fun observeWebsocketMessage(): ReceiveChannel<MessageItem>
fun observeWebsocketEvent(): ReceiveChannel<WebSocket.Event>
fun submitMessageToApi(message: String, isSender: Boolean)
fun getMessagesLiveData(): LiveData<List<MessageEntity>>
suspend fun addMessageItemToDatabase(messageItem: MessageItem)
suspend fun addTimeStamp(timestampMessage: String)
suspend fun getLastEntry(): MessageEntity?
}

View File

@@ -2,28 +2,54 @@ package com.example.h_mal.messengerapp.data.repository
import android.util.Log
import com.example.h_mal.messengerapp.data.network.MessengerApi
import com.example.h_mal.messengerapp.data.network.model.MessageItem
import com.example.h_mal.messengerapp.data.room.AppRoomDatabase
import com.example.h_mal.messengerapp.data.room.MessageEntity
import com.tinder.scarlet.WebSocket
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.consumeEach
import java.io.IOException
class RepositoryImpl(
private val messengerApi: MessengerApi
private val messengerApi: MessengerApi,
private val database: AppRoomDatabase
) : Repository{
override fun observeText(): ReceiveChannel<String> {
override fun observeWebsocketMessage(): ReceiveChannel<MessageItem> {
return messengerApi.observerMessage()
}
override fun submitMessage(message: String){
val send = messengerApi.send(message)
override fun observeWebsocketEvent(): ReceiveChannel<WebSocket.Event> {
return messengerApi.observerEvent()
}
// Send message to websocket or throw error if no connection
override fun submitMessageToApi(message: String, isSender: Boolean){
val messageItem = MessageItem(message, isSender)
val send = messengerApi.send(messageItem)
if (!send){
throw IOException("Could not send message")
}
}
override fun getEvent(): ReceiveChannel<WebSocket.Event.OnConnectionFailed> {
return messengerApi.observerFailure()
override fun getMessagesLiveData() = database.getMessageDao().getAllItems()
override suspend fun addMessageItemToDatabase(
messageItem: MessageItem
){
val entity = MessageEntity(messageItem)
database.getMessageDao().saveSingleItem(entity)
}
override suspend fun addTimeStamp(
timestampMessage: String
){
val entity = MessageEntity(timestampMessage)
database.getMessageDao().saveSingleItem(entity)
}
override suspend fun getLastEntry(): MessageEntity? {
return database.getMessageDao().getLastEntry()
}
}

View File

@@ -23,4 +23,7 @@ interface MessageDao {
@Delete
fun deleteSingleEntry(message: MessageEntity)
@Query("SELECT * FROM MessageEntity ORDER BY timestamp DESC LIMIT 1")
suspend fun getLastEntry(): MessageEntity?
}

View File

@@ -2,11 +2,32 @@ package com.example.h_mal.messengerapp.data.room
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.example.h_mal.messengerapp.data.network.model.MessageItem
const val TWO_HOURS = 20 * 1000
@Entity
data class MessageEntity(
@PrimaryKey(autoGenerate = true)
val id: Int,
val sender: Boolean,
val message: String
)
var isSender: Boolean? = null,
val message: String?,
val timeStamp: Long = System.currentTimeMillis()
){
constructor(timeStampMessage: String): this(
0, null, timeStampMessage
)
constructor(messageItem: MessageItem): this(
0,
messageItem.isSender,
messageItem.message,
messageItem.timeStamp
)
fun isGreaterThanTwoHours(): Boolean{
val current = System.currentTimeMillis()
val time = current - timeStamp
return time > TWO_HOURS
}
}

View File

@@ -5,16 +5,21 @@ import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.h_mal.messengerapp.R
import com.example.h_mal.messengerapp.utils.showToast
import kotlinx.android.synthetic.main.main_fragment.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.InternalCoroutinesApi
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
class MainFragment : Fragment(), KodeinAware {
// Kodein DI to retrieve @MainViewModelFactory
override val kodein by kodein()
private val factory by instance<MainViewModelFactory>()
@@ -22,8 +27,6 @@ class MainFragment : Fragment(), KodeinAware {
fun newInstance() = MainFragment()
}
@InternalCoroutinesApi
@ExperimentalCoroutinesApi
private lateinit var viewModel: MainViewModel
@@ -32,17 +35,53 @@ class MainFragment : Fragment(), KodeinAware {
return inflater.inflate(R.layout.main_fragment, container, false)
}
@InternalCoroutinesApi
@ExperimentalCoroutinesApi
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this, factory).get(MainViewModel::class.java)
// Toast viewmodel error messages
viewModel.operationFailure.observe(viewLifecycleOwner, Observer {
it.getContentIfNotHandled()?.let { operationFailure ->
context?.showToast(operationFailure)
}
})
message.setOnClickListener {
viewModel.submitMessage("dsfsdfsdjf")
message_recycler.apply {
layoutManager = LinearLayoutManager(requireContext()).apply {
stackFromEnd = true
}
setHasFixedSize(true)
// Apply adapter to recycler view
// anchor to the bottom when chats are being populated
adapter = MessageRecyclerAdapter(viewModel.messagesLiveData).apply {
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver(){
override fun onChanged() {
super.onChanged()
scrollToPosition(itemCount - 1)
}
})
}
}
message_box_et.apply {
setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_DONE ->{
viewModel.submitMessage(text.toString())
text.clear()
}
}
true
}
submit.setOnClickListener {
viewModel.submitMessage(text.toString())
text.clear()
}
}
}

View File

@@ -1,32 +1,95 @@
package com.example.h_mal.messengerapp.ui.main
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.h_mal.messengerapp.data.network.model.MessageItem
import com.example.h_mal.messengerapp.data.repository.Repository
import com.tinder.scarlet.Stream
import com.example.h_mal.messengerapp.utils.Event
import com.example.h_mal.messengerapp.utils.convertDateEpoch
import com.tinder.scarlet.WebSocket
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.*
import kotlin.concurrent.schedule
import kotlin.random.Random
@InternalCoroutinesApi
@ExperimentalCoroutinesApi
class MainViewModel(
private val repository: Repository
) : ViewModel() {
// get live data from the database
val messagesLiveData = repository.getMessagesLiveData()
// Event live data to push error messages to the UI
val operationFailure = MutableLiveData<Event<String>>()
init {
viewModelScope.launch {
repository.observeText().consumeEach {
Log.i("WebsocketOut", it)
repository.observeWebsocketMessage().consumeEach {
// add message to room database
addMessageItemToDatabase(it)
}
}
viewModelScope.launch {
repository.observeWebsocketEvent().consumeEach {
when(it){
is WebSocket.Event.OnConnectionOpened<*> ->{
saveDateStamp()
}
}
// Can handle different websocket events here
}
}
}
fun submitMessage(message: String){
repository.submitMessage(message)
if (message.isBlank()) return
try {
// Submit my message
repository.submitMessageToApi(message.trim(), true)
// Submit a random reply message with a delay of a second
Timer().schedule(1000){
repository.submitMessageToApi(randomString(), false)
}
}catch (exception: IOException){
val failureMessage = exception.message ?: "Failed to send"
operationFailure.postValue(Event(failureMessage))
}
}
private fun addMessageItemToDatabase(
messageItem: MessageItem
){
CoroutineScope(Dispatchers.IO).launch {
repository.addMessageItemToDatabase(messageItem)
}
}
private fun saveDateStamp(){
CoroutineScope(Dispatchers.IO).launch {
// get last entry from room database
val lastEntry = repository.getLastEntry() ?: return@launch
// if its greater than 2 hours
if (lastEntry.isGreaterThanTwoHours()){
lastEntry.timeStamp.convertDateEpoch()?.let {
repository.addTimeStamp(it)
}
}
}
}
// Creates a random string of 15 characters to simulate a reply
private fun randomString(): String {
val charPool = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
val randomString = (1..15)
.map { i -> Random.nextInt(0, charPool.length) }
.map(charPool::get)
.joinToString("");
return randomString
}
}

View File

@@ -0,0 +1,108 @@
package com.example.h_mal.messengerapp.ui.main
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.RecyclerView
import com.example.h_mal.messengerapp.R
import com.example.h_mal.messengerapp.data.room.MessageEntity
import com.example.h_mal.messengerapp.utils.setColour
import kotlinx.android.synthetic.main.simple_message_layout.view.*
import kotlinx.android.synthetic.main.timestamp_item_layout.view.*
const val SENT_CONST = 101
const val RECEIVED_CONST = 102
const val TIMESTAMP_CONST = 103
class MessageRecyclerAdapter(
val messageLiveData: LiveData<List<MessageEntity>>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var messages: List<MessageEntity>? = null
init {
// Observer live data and update list on change
messageLiveData.observeForever{
messages = it
notifyDataSetChanged()
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
return when(viewType){
SENT_CONST ->{
val view = LayoutInflater.from(parent.context).inflate(R.layout.message_right, parent, false)
CustomViewHolder(view)
}
RECEIVED_CONST ->{
val view = LayoutInflater.from(parent.context).inflate(R.layout.message_left, parent, false)
CustomViewHolder(view)
}
TIMESTAMP_CONST ->{
val view = LayoutInflater.from(parent.context).inflate(R.layout.timestamp_item_layout, parent, false)
TimestampViewHolder(view)
}
else -> EmptyViewHolder(parent.context)
}
}
override fun getItemCount(): Int {
return messages?.size ?: 0
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder){
is CustomViewHolder ->{
messages?.get(position)?.let {
holder.populateMessage(it)
}
}
is TimestampViewHolder ->{
messages?.get(position)?.let {
holder.populateMessage(it)
}
}
}
}
override fun getItemViewType(position: Int): Int {
messages?.get(position)?.isSender?.let {
return when(it){
true -> SENT_CONST
false -> RECEIVED_CONST
}
}
return TIMESTAMP_CONST
}
class CustomViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
private val messageTv: TextView = itemView.inner_text
private val card: CardView = itemView.card
fun populateMessage(messageEntity: MessageEntity){
messageTv.text = messageEntity.message
if (messageEntity.isSender!!){
card.setColour(R.color.senderColour)
}else{
card.setColour(R.color.receiverColour)
}
}
}
class TimestampViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
private val timestampTV: TextView = itemView.title
fun populateMessage(messageEntity: MessageEntity){
timestampTV.text = messageEntity.message
}
}
class EmptyViewHolder(context: Context): RecyclerView.ViewHolder(View(context))
}

View File

@@ -0,0 +1,16 @@
package com.example.h_mal.messengerapp.utils
import java.lang.Exception
import java.text.SimpleDateFormat
import java.util.*
fun Long.convertDateEpoch(): String? {
return try {
val sdf = SimpleDateFormat("dd/MM/yy hh:mm a", Locale.getDefault())
val date = Date(this)
sdf.format(date)
}catch (e: Exception){
e.printStackTrace()
null
}
}

View File

@@ -0,0 +1,24 @@
package com.example.h_mal.messengerapp.utils
open class Event<out T>(private val content: T) {
var hasBeenHandled = false
private set // Allow external read but not write
/**
* Returns the content and prevents its use again.
*/
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
/**
* Returns the content, even if it's already been handled.
*/
fun peekContent(): T = content
}

View File

@@ -0,0 +1,39 @@
package com.example.h_mal.messengerapp.utils
import android.app.Activity
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.StringRes
import androidx.cardview.widget.CardView
import com.example.h_mal.messengerapp.R
fun View.show() {
visibility = View.VISIBLE
}
fun View.hide() {
visibility = View.GONE
}
fun Context.showToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
fun Context.showToast(@StringRes resourceId: Int) {
Toast.makeText(this, resourceId, Toast.LENGTH_LONG).show()
}
fun View.hideKeyboard() {
val inputMethodManager =
context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(windowToken, 0)
}
fun CardView.setColour(@ColorRes id: Int){
val colour = context.getColor(id)
setCardBackgroundColor(colour)
}

View File

@@ -5,16 +5,50 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context=".ui.main.MainFragment">
<TextView
android:id="@+id/message"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/message_recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="@dimen/activity_vertical_margin"
app:layout_constraintBottom_toTopOf="@id/message_typer_coptainer"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/message_left"/>
<LinearLayout
android:id="@+id/message_typer_coptainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<EditText
android:id="@+id/message_box_et"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/activity_vertical_margin"
android:layout_weight="1"
android:maxLines="4"
android:imeOptions="actionDone"
android:inputType="text"
tools:lines="4" />
<ImageView
android:id="@+id/submit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MainFragment"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:layout_gravity="center|top"
android:adjustViewBounds="true"
android:src="@android:drawable/ic_menu_send" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
xmlns:app="http://schemas.android.com/apk/res-auto">
<include
layout="@layout/simple_message_layout"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dp"
android:elevation="0dp"
app:layout_constraintWidth_percent="0.8"
android:layout_height="wrap_content" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto">
<include
layout="@layout/simple_message_layout"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dp"
app:layout_constraintWidth_percent="0.8"
android:layout_height="wrap_content" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<androidx.cardview.widget.CardView
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="12dp"
android:layout_margin="6dp"
tools:cardBackgroundColor="@color/senderColour">
<TextView
style="@style/TextAppearance.AppCompat"
android:elevation="4dp"
android:id="@+id/inner_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
tools:text="@string/dummy_long_text" />
</androidx.cardview.widget.CardView>
</FrameLayout>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:id="@+id/title"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearancePopupMenuHeader"
android:gravity="center"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:layout_width="match_parent"
android:layout_height="?android:attr/listPreferredItemHeightSmall"
tools:text="Title"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -3,4 +3,7 @@
<color name="colorPrimary">#6200EE</color>
<color name="colorPrimaryDark">#3700B3</color>
<color name="colorAccent">#03DAC5</color>
<color name="senderColour">#BB8AFF</color>
<color name="receiverColour">#EBE2F6</color>
</resources>

View File

@@ -0,0 +1,5 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
</resources>

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">MessengerApp</string>
<string name="dummy_long_text">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</string>
</resources>