Rename app to vector

This commit is contained in:
Benoit Marty
2019-03-19 14:38:15 +01:00
parent 04b4f32e16
commit 9048a1dbbe
305 changed files with 71 additions and 71 deletions

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="im.vector.riotredesign">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".Riot"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme.Light"
tools:replace="android:allowBackup">
<activity
android:name=".features.MainActivity"
android:theme="@style/Theme.Riot.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".features.home.HomeActivity" />
<activity android:name=".features.login.LoginActivity" />
<activity android:name=".features.media.MediaViewerActivity" />
<activity
android:name=".features.rageshake.BugReportActivity"
android:label="@string/title_activity_bug_report" />
</application>
</manifest>

View File

@ -0,0 +1,69 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign
import android.app.Application
import android.content.Context
import androidx.multidex.MultiDex
import com.airbnb.epoxy.EpoxyAsyncUtil
import com.airbnb.epoxy.EpoxyController
import com.facebook.stetho.Stetho
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.glide.GlideImageLoader
import com.jakewharton.threetenabp.AndroidThreeTen
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.core.di.AppModule
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.rageshake.VectorFileLogger
import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler
import org.koin.log.EmptyLogger
import org.koin.standalone.StandAloneContext.startKoin
import timber.log.Timber
class Riot : Application() {
override fun onCreate() {
super.onCreate()
VectorUncaughtExceptionHandler.activate(this)
// Log
VectorFileLogger.init(this)
Timber.plant(Timber.DebugTree(), VectorFileLogger)
if (BuildConfig.DEBUG) {
Stetho.initializeWithDefaults(this)
}
AndroidThreeTen.init(this)
BigImageViewer.initialize(GlideImageLoader.with(applicationContext))
EpoxyController.defaultDiffingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
EpoxyController.defaultModelBuildingHandler = EpoxyAsyncUtil.getAsyncBackgroundHandler()
val appModule = AppModule(applicationContext).definition
val homeModule = HomeModule().definition
startKoin(listOf(appModule, homeModule), logger = EmptyLogger())
Matrix.getInstance().setApplicationFlavor(BuildConfig.FLAVOR_DESCRIPTION)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
MultiDex.install(this)
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.di
import android.content.Context
import android.content.Context.MODE_PRIVATE
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.resources.LocaleProvider
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.group.SelectedGroupStore
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import im.vector.riotredesign.features.home.room.list.RoomSelectionRepository
import im.vector.riotredesign.features.home.room.list.RoomSummaryComparator
import org.koin.dsl.module.module
class AppModule(private val context: Context) {
val definition = module {
single {
LocaleProvider(context.resources)
}
single {
StringProvider(context.resources)
}
single {
ColorProvider(context)
}
single {
context.getSharedPreferences("im.vector.riot", MODE_PRIVATE)
}
single {
RoomSelectionRepository(get())
}
single {
SelectedGroupStore()
}
single {
VisibleRoomStore()
}
single {
RoomSummaryComparator()
}
factory {
Matrix.getInstance().currentSession!!
}
}
}

View File

@ -0,0 +1,27 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package im.vector.riotredesign.core.epoxy
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
@EpoxyModelClass(layout = R.layout.item_empty)
abstract class EmptyItem : RiotEpoxyModel<EmptyItem.Holder>() {
class Holder : RiotEpoxyHolder()
}

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.epoxy
import android.os.Bundle
import android.os.Parcelable
import androidx.recyclerview.widget.RecyclerView
import im.vector.riotredesign.core.platform.DefaultListUpdateCallback
import im.vector.riotredesign.core.platform.Restorable
import java.util.concurrent.atomic.AtomicReference
private const val LAYOUT_MANAGER_STATE = "LAYOUT_MANAGER_STATE"
class LayoutManagerStateRestorer(private val layoutManager: RecyclerView.LayoutManager) : Restorable, DefaultListUpdateCallback {
private var layoutManagerState = AtomicReference<Parcelable?>()
override fun onSaveInstanceState(outState: Bundle) {
val layoutManagerState = layoutManager.onSaveInstanceState()
outState.putParcelable(LAYOUT_MANAGER_STATE, layoutManagerState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
val parcelable = savedInstanceState?.getParcelable<Parcelable>(LAYOUT_MANAGER_STATE)
layoutManagerState.set(parcelable)
}
override fun onInserted(position: Int, count: Int) {
layoutManagerState.getAndSet(null)?.also {
layoutManager.onRestoreInstanceState(it)
}
}
}

View File

@ -0,0 +1,24 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.epoxy
import android.content.Context
import android.widget.ProgressBar
import com.airbnb.epoxy.ModelView
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class LoadingItem(context: Context) : ProgressBar(context)

View File

@ -0,0 +1,61 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.epoxy
import android.view.View
import com.airbnb.epoxy.EpoxyHolder
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
/**
* A pattern for easier view binding with an [EpoxyHolder]
*
* See [SampleKotlinModelWithHolder] for a usage example.
*/
abstract class RiotEpoxyHolder : EpoxyHolder() {
private lateinit var view: View
override fun bindView(itemView: View) {
view = itemView
}
protected fun <V : View> bind(id: Int): ReadOnlyProperty<RiotEpoxyHolder, V> =
Lazy { holder: RiotEpoxyHolder, prop ->
holder.view.findViewById(id) as V?
?: throw IllegalStateException("View ID $id for '${prop.name}' not found.")
}
/**
* Taken from Kotterknife.
* https://github.com/JakeWharton/kotterknife
*/
private class Lazy<V>(
private val initializer: (RiotEpoxyHolder, KProperty<*>) -> V
) : ReadOnlyProperty<RiotEpoxyHolder, V> {
private object EMPTY
private var value: Any? = EMPTY
override fun getValue(thisRef: RiotEpoxyHolder, property: KProperty<*>): V {
if (value == EMPTY) {
value = initializer(thisRef, property)
}
@Suppress("UNCHECKED_CAST")
return value as V
}
}
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.epoxy
import com.airbnb.epoxy.EpoxyModelWithHolder
import com.airbnb.epoxy.VisibilityState
abstract class RiotEpoxyModel<H : RiotEpoxyHolder> : EpoxyModelWithHolder<H>() {
private var onModelVisibilityStateChangedListener: OnVisibilityStateChangedListener? = null
override fun onVisibilityStateChanged(visibilityState: Int, view: H) {
onModelVisibilityStateChangedListener?.onVisibilityStateChanged(visibilityState)
super.onVisibilityStateChanged(visibilityState, view)
}
fun setOnVisibilityStateChanged(listener: OnVisibilityStateChangedListener): RiotEpoxyModel<H> {
this.onModelVisibilityStateChangedListener = listener
return this
}
interface OnVisibilityStateChangedListener {
fun onVisibilityStateChanged(@VisibilityState.Visibility visibilityState: Int)
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
fun AppCompatActivity.addFragment(fragment: Fragment, frameId: Int) {
supportFragmentManager.inTransaction { add(frameId, fragment) }
}
fun AppCompatActivity.replaceFragment(fragment: Fragment, frameId: Int) {
supportFragmentManager.inTransaction { replace(frameId, fragment) }
}
fun AppCompatActivity.addFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
supportFragmentManager.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
}
fun AppCompatActivity.hideKeyboard() {
currentFocus?.hideKeyboard()
}

View File

@ -0,0 +1,27 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import android.os.Bundle
import androidx.fragment.app.Fragment
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
/**
* Apply argument to a Fragment
*/
fun <T : Fragment> T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bundle().apply(block) }

View File

@ -0,0 +1,60 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import im.vector.riotredesign.R
fun EditText.setupAsSearch() {
addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(editable: Editable?) {
val clearIcon = if (editable?.isNotEmpty() == true) R.drawable.ic_clear_white else 0
setCompoundDrawablesWithIntrinsicBounds(0, 0, clearIcon, 0)
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
})
maxLines = 1
inputType = InputType.TYPE_CLASS_TEXT
imeOptions = EditorInfo.IME_ACTION_SEARCH
setOnEditorActionListener { _, actionId, event ->
var consumed = false
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
hideKeyboard()
consumed = true
}
consumed
}
setOnTouchListener(View.OnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_UP) {
if (event.rawX >= (this.right - this.compoundPaddingRight)) {
text = null
return@OnTouchListener true
}
}
return@OnTouchListener false
})
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.riotredesign.core.resources.DateProvider
import org.threeten.bp.LocalDateTime
fun Event.localDateTime(): LocalDateTime {
return DateProvider.toLocalDateTime(originServerTs)
}

View File

@ -0,0 +1,43 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import androidx.fragment.app.Fragment
fun androidx.fragment.app.Fragment.addFragment(fragment: Fragment, frameId: Int) {
fragmentManager?.inTransaction { add(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.replaceFragment(fragment: Fragment, frameId: Int) {
fragmentManager?.inTransaction { replace(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.addFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
fragmentManager?.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
}
fun androidx.fragment.app.Fragment.addChildFragment(fragment: Fragment, frameId: Int) {
childFragmentManager.inTransaction { add(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.replaceChildFragment(fragment: Fragment, frameId: Int) {
childFragmentManager.inTransaction { replace(frameId, fragment) }
}
fun androidx.fragment.app.Fragment.addChildFragmentToBackstack(fragment: Fragment, frameId: Int, tag: String? = null) {
childFragmentManager.inTransaction { replace(frameId, fragment).addToBackStack(tag) }
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import androidx.fragment.app.FragmentTransaction
inline fun androidx.fragment.app.FragmentManager.inTransaction(func: FragmentTransaction.() -> FragmentTransaction) {
beginTransaction().func().commit()
}

View File

@ -0,0 +1,38 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package im.vector.riotredesign.core.extensions
/**
* Returns the last element yielding the smallest value of the given function or `null` if there are no elements.
*/
public inline fun <T, R : Comparable<R>> Iterable<T>.lastMinBy(selector: (T) -> R): T? {
val iterator = iterator()
if (!iterator.hasNext()) return null
var minElem = iterator.next()
var minValue = selector(minElem)
while (iterator.hasNext()) {
val e = iterator.next()
val v = selector(e)
if (minValue >= v) {
minElem = e
minValue = v
}
}
return minElem
}

View File

@ -0,0 +1,35 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.core.utils.EventObserver
inline fun <T> LiveData<T>.observeK(owner: LifecycleOwner, crossinline observer: (T?) -> Unit) {
this.observe(owner, Observer { observer(it) })
}
inline fun <T> LiveData<T>.observeNotNull(owner: LifecycleOwner, crossinline observer: (T) -> Unit) {
this.observe(owner, Observer { it?.run(observer) })
}
inline fun <T> LiveData<LiveEvent<T>>.observeEvent(owner: LifecycleOwner, crossinline observer: (T) -> Unit) {
this.observe(owner, EventObserver { it.run(observer) })
}

View File

@ -0,0 +1,26 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.extensions
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
fun View.hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0)
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.glide;
import android.content.Context;
import android.util.Log;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;
@GlideModule
public final class MyAppGlideModule extends AppGlideModule {
@Override
public void applyOptions(Context context, GlideBuilder builder) {
builder.setLogLevel(Log.ERROR);
}
}

View File

@ -0,0 +1,61 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.Checkable
import android.widget.FrameLayout
class CheckableFrameLayout : FrameLayout, Checkable {
private var mChecked = false
constructor(context: Context) : super(context) {}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}
override fun isChecked(): Boolean {
return mChecked
}
override fun setChecked(b: Boolean) {
if (b != mChecked) {
mChecked = b
refreshDrawableState()
}
}
override fun toggle() {
isChecked = !mChecked
}
public override fun onCreateDrawableState(extraSpace: Int): IntArray {
val drawableState = super.onCreateDrawableState(extraSpace + 1)
if (isChecked) {
View.mergeDrawableStates(drawableState, CHECKED_STATE_SET)
}
return drawableState
}
companion object {
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked)
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import androidx.recyclerview.widget.ListUpdateCallback
interface DefaultListUpdateCallback : ListUpdateCallback {
override fun onChanged(position: Int, count: Int, tag: Any?) {
//no-op
}
override fun onMoved(position: Int, count: Int) {
//no-op
}
override fun onInserted(position: Int, count: Int) {
//no-op
}
override fun onRemoved(position: Int, count: Int) {
//no-op
}
}

View File

@ -0,0 +1,26 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
interface OnBackPressed {
/**
* Returns true, if the on back pressed event has been handled by this Fragment.
* Otherwise return false
*/
fun onBackPressed(): Boolean
}

View File

@ -0,0 +1,27 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import android.os.Bundle
interface Restorable {
fun onSaveInstanceState(outState: Bundle)
fun onRestoreInstanceState(savedInstanceState: Bundle?)
}

View File

@ -0,0 +1,272 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import android.content.res.Configuration
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.annotation.*
import androidx.appcompat.widget.Toolbar
import butterknife.BindView
import butterknife.ButterKnife
import butterknife.Unbinder
import com.airbnb.mvrx.BaseMvRxActivity
import com.bumptech.glide.util.Util
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import im.vector.riotredesign.features.rageshake.BugReportActivity
import im.vector.riotredesign.features.rageshake.BugReporter
import im.vector.riotredesign.features.rageshake.RageShake
import im.vector.riotredesign.features.themes.ThemeUtils
import im.vector.riotredesign.receivers.DebugReceiver
import im.vector.ui.themes.ActivityOtherThemes
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import timber.log.Timber
abstract class RiotActivity : BaseMvRxActivity() {
/* ==========================================================================================
* UI
* ========================================================================================== */
@Nullable
@BindView(R.id.toolbar)
protected lateinit var toolbar: Toolbar
/* ==========================================================================================
* DATA
* ========================================================================================== */
private var unBinder: Unbinder? = null
private var savedInstanceState: Bundle? = null
// For debug only
private var debugReceiver: DebugReceiver? = null
private val uiDisposables = CompositeDisposable()
private val restorables = ArrayList<Restorable>()
private var rageShake: RageShake? = null
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
restorables.forEach { it.onSaveInstanceState(outState) }
}
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
restorables.forEach { it.onRestoreInstanceState(savedInstanceState) }
super.onRestoreInstanceState(savedInstanceState)
}
@MainThread
protected fun <T : Restorable> T.register(): T {
Util.assertMainThread()
restorables.add(this)
return this
}
protected fun Disposable.disposeOnDestroy(): Disposable {
uiDisposables.add(this)
return this
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Shake detector
rageShake = RageShake(this)
ThemeUtils.setActivityTheme(this, getOtherThemes())
doBeforeSetContentView()
if (getLayoutRes() != -1) {
setContentView(getLayoutRes())
}
unBinder = ButterKnife.bind(this)
this.savedInstanceState = savedInstanceState
initUiAndData()
val titleRes = getTitleRes()
if (titleRes != -1) {
supportActionBar?.let {
it.setTitle(titleRes)
} ?: run {
setTitle(titleRes)
}
}
}
override fun onDestroy() {
super.onDestroy()
unBinder?.unbind()
unBinder = null
}
override fun onResume() {
super.onResume()
if (this !is BugReportActivity) {
rageShake?.start()
}
DebugReceiver
.getIntentFilter(this)
.takeIf { BuildConfig.DEBUG }
?.let {
debugReceiver = DebugReceiver()
registerReceiver(debugReceiver, it)
}
}
override fun onPause() {
super.onPause()
rageShake?.stop()
debugReceiver?.let {
unregisterReceiver(debugReceiver)
debugReceiver = null
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus && displayInFullscreen()) {
setFullScreen()
}
}
override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) {
super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig)
Timber.w("onMultiWindowModeChanged. isInMultiWindowMode: $isInMultiWindowMode")
BugReporter.inMultiWindowMode = isInMultiWindowMode
}
/* ==========================================================================================
* PRIVATE METHODS
* ========================================================================================== */
/**
* Force to render the activity in fullscreen
*/
private fun setFullScreen() {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
}
/* ==========================================================================================
* MENU MANAGEMENT
* ========================================================================================== */
final override fun onCreateOptionsMenu(menu: Menu): Boolean {
val menuRes = getMenuRes()
if (menuRes != -1) {
menuInflater.inflate(menuRes, menu)
ThemeUtils.tintMenuIcons(menu, ThemeUtils.getColor(this, getMenuTint()))
return true
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
setResult(RESULT_CANCELED)
finish()
return true
}
return super.onOptionsItemSelected(item)
}
/* ==========================================================================================
* PROTECTED METHODS
* ========================================================================================== */
/**
* Get the saved instance state.
* Ensure {@link isFirstCreation()} returns false before calling this
*
* @return
*/
protected fun getSavedInstanceState(): Bundle {
return savedInstanceState!!
}
/**
* Is first creation
*
* @return true if Activity is created for the first time (and not restored by the system)
*/
protected fun isFirstCreation() = savedInstanceState == null
/**
* Configure the Toolbar. It MUST be present in your layout with id "toolbar"
*/
protected fun configureToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.let {
it.setDisplayShowHomeEnabled(true)
it.setDisplayHomeAsUpEnabled(true)
}
}
/* ==========================================================================================
* OPEN METHODS
* ========================================================================================== */
@LayoutRes
open fun getLayoutRes() = -1
open fun displayInFullscreen() = false
open fun doBeforeSetContentView() = Unit
open fun initUiAndData() = Unit
@StringRes
open fun getTitleRes() = -1
@MenuRes
open fun getMenuRes() = -1
@AttrRes
open fun getMenuTint() = R.attr.vctr_icon_tint_on_dark_action_bar_color
/**
* Return a object containing other themes for this activity
*/
open fun getOtherThemes(): ActivityOtherThemes = ActivityOtherThemes.Default
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import android.os.Bundle
import android.os.Parcelable
import androidx.annotation.MainThread
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.bumptech.glide.util.Util.assertMainThread
abstract class RiotFragment : BaseMvRxFragment(), OnBackPressed {
val riotActivity: RiotActivity by lazy {
activity as RiotActivity
}
private val restorables = ArrayList<Restorable>()
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
restorables.forEach { it.onSaveInstanceState(outState) }
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
restorables.forEach { it.onRestoreInstanceState(savedInstanceState) }
super.onViewStateRestored(savedInstanceState)
}
override fun onBackPressed(): Boolean {
return false
}
override fun invalidate() {
//no-ops by default
}
protected fun setArguments(args: Parcelable? = null) {
arguments = args?.let { Bundle().apply { putParcelable(MvRx.KEY_ARG, it) } }
}
@MainThread
protected fun <T : Restorable> T.register(): T {
assertMainThread()
restorables.add(this)
return this
}
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import com.airbnb.mvrx.BaseMvRxViewModel
import com.airbnb.mvrx.MvRxState
abstract class RiotViewModel<S : MvRxState>(initialState: S)
: BaseMvRxViewModel<S>(initialState, debugMode = false)

View File

@ -0,0 +1,97 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import im.vector.riotredesign.R
import kotlinx.android.synthetic.main.view_state.view.*
class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0)
: FrameLayout(context, attrs, defStyle) {
sealed class State {
object Content : State()
object Loading : State()
data class Empty(val message: CharSequence? = null) : State()
data class Error(val message: CharSequence? = null) : State()
}
private var eventCallback: EventCallback? = null
var contentView: View? = null
var state: State = State.Empty()
set(newState) {
if (newState != state) {
update(newState)
}
}
interface EventCallback {
fun onRetryClicked()
}
init {
View.inflate(context, R.layout.view_state, this)
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
errorRetryView.setOnClickListener {
eventCallback?.onRetryClicked()
}
state = State.Content
}
private fun update(newState: State) {
when (newState) {
is StateView.State.Content -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.INVISIBLE
contentView?.visibility = View.VISIBLE
}
is StateView.State.Loading -> {
progressBar.visibility = View.VISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.INVISIBLE
contentView?.visibility = View.INVISIBLE
}
is StateView.State.Empty -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.VISIBLE
emptyMessageView.text = newState.message
if (contentView != null) {
contentView!!.visibility = View.INVISIBLE
}
}
is StateView.State.Error -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.VISIBLE
emptyView.visibility = View.INVISIBLE
errorMessageView.text = newState.message
if (contentView != null) {
contentView!!.visibility = View.INVISIBLE
}
}
}
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.platform
import androidx.appcompat.widget.Toolbar
interface ToolbarConfigurable {
fun configure(toolbar: Toolbar)
}

View File

@ -0,0 +1,31 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package im.vector.riotredesign.core.resources
import android.content.Context
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
class ColorProvider(private val context: Context) {
fun getColor(@ColorRes colorRes: Int): Int {
return ContextCompat.getColor(context, colorRes)
}
}

View File

@ -0,0 +1,32 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.resources
import org.threeten.bp.Instant
import org.threeten.bp.LocalDateTime
import org.threeten.bp.ZoneId
object DateProvider {
private val zoneId = ZoneId.systemDefault()
fun toLocalDateTime(timestamp: Long?): LocalDateTime {
val instant = Instant.ofEpochMilli(timestamp ?: 0)
return LocalDateTime.ofInstant(instant, zoneId)
}
}

View File

@ -0,0 +1,30 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.resources
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import java.util.*
class LocaleProvider(private val resources: Resources) {
fun current(): Locale {
return ConfigurationCompat.getLocales(resources.configuration)[0]
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.resources
import android.content.res.Resources
import androidx.annotation.NonNull
import androidx.annotation.StringRes
class StringProvider(private val resources: Resources) {
/**
* Returns a localized string from the application's package's
* default string table.
*
* @param resId Resource id for the string
* @return The string data associated with the resource, stripped of styled
* text information.
*/
@NonNull
fun getString(@StringRes resId: Int): String {
return resources.getString(resId)
}
/**
* Returns a localized formatted string from the application's package's
* default string table, substituting the format arguments as defined in
* [java.util.Formatter] and [java.lang.String.format].
*
* @param resId Resource id for the format string
* @param formatArgs The format arguments that will be used for
* substitution.
* @return The string data associated with the resource, formatted and
* stripped of styled text information.
*/
@NonNull
fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String {
return resources.getString(resId, *formatArgs)
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.utils
import android.content.Context
import timber.log.Timber
import java.io.File
// Implementation should return true in case of success
typealias ActionOnFile = (file: File) -> Boolean
/* ==========================================================================================
* Delete
* ========================================================================================== */
fun deleteAllFiles(context: Context) {
Timber.v("Delete cache dir:")
recursiveActionOnFile(context.cacheDir, ::deleteAction)
Timber.v("Delete files dir:")
recursiveActionOnFile(context.filesDir, ::deleteAction)
}
private fun deleteAction(file: File): Boolean {
if (file.exists()) {
Timber.v("deleteFile: $file")
return file.delete()
}
return true
}
/* ==========================================================================================
* Log
* ========================================================================================== */
fun lsFiles(context: Context) {
Timber.v("Content of cache dir:")
recursiveActionOnFile(context.cacheDir, ::logAction)
Timber.v("Content of files dir:")
recursiveActionOnFile(context.filesDir, ::logAction)
}
private fun logAction(file: File): Boolean {
if (file.isDirectory) {
Timber.d(file.toString())
} else {
Timber.d(file.toString() + " " + file.length() + " bytes")
}
return true
}
/* ==========================================================================================
* Private
* ========================================================================================== */
/**
* Return true in case of success
*/
private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean {
if (file.isDirectory) {
file.list().forEach {
val result = recursiveActionOnFile(File(file, it), action)
if (!result) {
// Break the loop
return false
}
}
}
return action.invoke(file)
}

View File

@ -0,0 +1,56 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.utils
import androidx.lifecycle.Observer
open class LiveEvent<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
}
/**
* An [Observer] for [LiveEvent]s, simplifying the pattern of checking if the [LiveEvent]'s content has
* already been handled.
*
* [onEventUnhandledContent] is *only* called if the [LiveEvent]'s contents has not been handled.
*/
class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit) : Observer<LiveEvent<T>> {
override fun onChanged(event: LiveEvent<T>?) {
event?.getContentIfNotHandled()?.let { value ->
onEventUnhandledContent(value)
}
}
}

View File

@ -0,0 +1,37 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.utils
import com.jakewharton.rxrelay2.BehaviorRelay
import io.reactivex.Observable
open class RxStore<T>(defaultValue: T? = null) {
private val storeSubject: BehaviorRelay<T> = if (defaultValue == null) {
BehaviorRelay.create<T>()
} else {
BehaviorRelay.createDefault(defaultValue)
}
fun observe(): Observable<T> {
return storeSubject.hide().distinctUntilChanged()
}
fun post(value: T) {
storeSubject.accept(value)
}
}

View File

@ -0,0 +1,186 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.utils
import android.annotation.TargetApi
import android.app.Activity
import android.content.*
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.fragment.app.Fragment
import im.vector.riotredesign.R
import im.vector.riotredesign.features.settings.VectorLocale
import timber.log.Timber
import java.util.*
/**
* Tells if the application ignores battery optimizations.
*
* Ignoring them allows the app to run in background to make background sync with the homeserver.
* This user option appears on Android M but Android O enforces its usage and kills apps not
* authorised by the user to run in background.
*
* @param context the context
* @return true if battery optimisations are ignored
*/
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
// no issue before Android M, battery optimisations did not exist
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| (context.getSystemService(Context.POWER_SERVICE) as PowerManager?)?.isIgnoringBatteryOptimizations(context.packageName) == true
}
/**
* display the system dialog for granting this permission. If previously granted, the
* system will not show it (so you should call this method).
*
* Note: If the user finally does not grant the permission, PushManager.isBackgroundSyncAllowed()
* will return false and the notification privacy will fallback to "LOW_DETAIL".
*/
@TargetApi(Build.VERSION_CODES.M)
fun requestDisablingBatteryOptimization(activity: Activity, fragment: Fragment?, requestCode: Int) {
val intent = Intent()
intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
intent.data = Uri.parse("package:" + activity.packageName)
if (fragment != null) {
fragment.startActivityForResult(intent, requestCode)
} else {
activity.startActivityForResult(intent, requestCode)
}
}
//==============================================================================================================
// Clipboard helper
//==============================================================================================================
/**
* Copy a text to the clipboard, and display a Toast when done
*
* @param context the context
* @param text the text to copy
*/
fun copyToClipboard(context: Context, text: CharSequence) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.primaryClip = ClipData.newPlainText("", text)
context.toast(R.string.copied_to_clipboard)
}
/**
* Provides the device locale
*
* @return the device locale
*/
fun getDeviceLocale(context: Context): Locale {
var locale: Locale
locale = try {
val packageManager = context.packageManager
val resources = packageManager.getResourcesForApplication("android")
resources.configuration.locale
} catch (e: Exception) {
Timber.e(e, "## getDeviceLocale() failed " + e.message)
// Fallback to application locale
VectorLocale.applicationLocale
}
return locale
}
/**
* Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings
*/
fun startNotificationSettingsIntent(fragment: Fragment, requestCode: Int) {
val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, fragment.context?.packageName)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra("app_package", fragment.context?.packageName)
intent.putExtra("app_uid", fragment.context?.applicationInfo?.uid)
} else {
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.addCategory(Intent.CATEGORY_DEFAULT);
val uri = Uri.fromParts("package", fragment.activity?.packageName, null)
intent.data = uri
}
fragment.startActivityForResult(intent, requestCode)
}
// TODO This comes from NotificationUtils
fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
/**
* Shows notification system settings for the given channel id.
*/
@TargetApi(Build.VERSION_CODES.O)
fun startNotificationChannelSettingsIntent(fragment: Fragment, channelID: String) {
if (!supportNotificationChannels()) return
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, fragment.context?.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelID)
}
fragment.startActivity(intent)
}
fun startAddGoogleAccountIntent(fragment: Fragment, requestCode: Int) {
try {
val intent = Intent(Settings.ACTION_ADD_ACCOUNT)
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google"))
fragment.startActivityForResult(intent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
fragment.activity?.toast(R.string.error_no_external_application_found)
}
}
fun startSharePlainTextIntent(fragment: Fragment, chooserTitle: String?, text: String, subject: String? = null) {
val share = Intent(Intent.ACTION_SEND)
share.type = "text/plain"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
share.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
} else {
share.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Add data to the intent, the receiving app will decide what to do with it.
share.putExtra(Intent.EXTRA_SUBJECT, subject)
share.putExtra(Intent.EXTRA_TEXT, text)
try {
fragment.startActivity(Intent.createChooser(share, chooserTitle))
} catch (activityNotFoundException: ActivityNotFoundException) {
fragment.activity?.toast(R.string.error_no_external_application_found)
}
}
fun startImportTextFromFileIntent(fragment: Fragment, requestCode: Int) {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "text/plain"
}
if (intent.resolveActivity(fragment.activity!!.packageManager) != null) {
fragment.startActivityForResult(intent, requestCode)
} else {
fragment.activity?.toast(R.string.error_no_external_application_found)
}
}
// Not in KTX anymore
fun Context.toast(resId: Int) {
Toast.makeText(this, resId, Toast.LENGTH_SHORT).show()
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features
import android.os.Bundle
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.core.platform.RiotActivity
import im.vector.riotredesign.features.home.HomeActivity
import im.vector.riotredesign.features.login.LoginActivity
class MainActivity : RiotActivity() {
private val authenticator = Matrix.getInstance().authenticator()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = if (authenticator.hasActiveSessions()) {
HomeActivity.newIntent(this)
} else {
LoginActivity.newIntent(this)
}
startActivity(intent)
finish()
}
}

View File

@ -0,0 +1,102 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home
import android.content.Context
import android.graphics.drawable.Drawable
import android.widget.ImageView
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.core.content.ContextCompat
import com.amulyakhare.textdrawable.TextDrawable
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.DrawableImageViewTarget
import com.bumptech.glide.request.target.Target
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.glide.GlideRequest
import im.vector.riotredesign.core.glide.GlideRequests
/**
* This helper centralise ways to retrieve avatar into ImageView or even generic Target<Drawable>
*/
object AvatarRenderer {
private const val THUMBNAIL_SIZE = 250
@UiThread
fun render(roomMember: RoomMember, imageView: ImageView) {
render(roomMember.avatarUrl, roomMember.displayName, imageView)
}
@UiThread
fun render(roomSummary: RoomSummary, imageView: ImageView) {
render(roomSummary.avatarUrl, roomSummary.displayName, imageView)
}
@UiThread
fun render(avatarUrl: String?, name: String?, imageView: ImageView) {
render(imageView.context, GlideApp.with(imageView), avatarUrl, name, DrawableImageViewTarget(imageView))
}
@UiThread
fun render(context: Context,
glideRequest: GlideRequests,
avatarUrl: String?,
name: String?,
target: Target<Drawable>) {
if (name.isNullOrEmpty()) {
return
}
val placeholder = getPlaceholderDrawable(context, name)
buildGlideRequest(glideRequest, avatarUrl)
.placeholder(placeholder)
.into(target)
}
@AnyThread
fun getPlaceholderDrawable(context: Context, text: String): Drawable {
val avatarColor = ContextCompat.getColor(context, R.color.pale_teal)
return if (text.isEmpty()) {
TextDrawable.builder().buildRound("", avatarColor)
} else {
val isUserId = MatrixPatterns.isUserId(text)
val firstLetterIndex = if (isUserId) 1 else 0
val firstLetter = text[firstLetterIndex].toString().toUpperCase()
TextDrawable.builder().buildRound(firstLetter, avatarColor)
}
}
// PRIVATE API *********************************************************************************
private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest<Drawable> {
val resolvedUrl = Matrix.getInstance().currentSession!!.contentUrlResolver()
.resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)
return glideRequest
.load(resolvedUrl)
.apply(RequestOptions.circleCropTransform())
}
}

View File

@ -0,0 +1,151 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.FragmentManager
import com.airbnb.mvrx.viewModel
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.hideKeyboard
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.extensions.replaceFragment
import im.vector.riotredesign.core.platform.OnBackPressed
import im.vector.riotredesign.core.platform.RiotActivity
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.features.home.room.detail.LoadingRoomDetailFragment
import im.vector.riotredesign.features.rageshake.BugReporter
import im.vector.riotredesign.features.rageshake.VectorUncaughtExceptionHandler
import kotlinx.android.synthetic.main.activity_home.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
class HomeActivity : RiotActivity(), ToolbarConfigurable {
private val homeActivityViewModel: HomeActivityViewModel by viewModel()
private val homeNavigator by inject<HomeNavigator>()
private val drawerListener = object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerStateChanged(newState: Int) {
hideKeyboard()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_home)
bindScope(getOrCreateScope(HomeModule.HOME_SCOPE))
homeNavigator.activity = this
drawerLayout.addDrawerListener(drawerListener)
if (savedInstanceState == null) {
val homeDrawerFragment = HomeDrawerFragment.newInstance()
val loadingDetail = LoadingRoomDetailFragment.newInstance()
replaceFragment(loadingDetail, R.id.homeDetailFragmentContainer)
replaceFragment(homeDrawerFragment, R.id.homeDrawerFragmentContainer)
}
homeActivityViewModel.openRoomLiveData.observeEvent(this) {
homeNavigator.openRoomDetail(it, null)
}
}
override fun onDestroy() {
drawerLayout.removeDrawerListener(drawerListener)
homeNavigator.activity = null
super.onDestroy()
}
override fun onResume() {
super.onResume()
if (VectorUncaughtExceptionHandler.didAppCrash(this)) {
VectorUncaughtExceptionHandler.clearAppCrashStatus(this)
AlertDialog.Builder(this)
.setMessage(R.string.send_bug_report_app_crashed)
.setCancelable(false)
.setPositiveButton(R.string.yes) { _, _ -> BugReporter.openBugReportScreen(this) }
.setNegativeButton(R.string.no) { _, _ -> BugReporter.deleteCrashFile(this) }
.show()
}
}
override fun configure(toolbar: Toolbar) {
setSupportActionBar(toolbar)
supportActionBar?.setHomeButtonEnabled(true)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val drawerToggle = ActionBarDrawerToggle(this, drawerLayout, toolbar, 0, 0)
drawerLayout.addDrawerListener(drawerToggle)
drawerToggle.syncState()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
drawerLayout.openDrawer(GravityCompat.START)
return true
}
}
return true
}
override fun onBackPressed() {
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
drawerLayout.closeDrawer(GravityCompat.START)
} else {
val handled = recursivelyDispatchOnBackPressed(supportFragmentManager)
if (!handled) {
super.onBackPressed()
}
}
}
private fun recursivelyDispatchOnBackPressed(fm: FragmentManager): Boolean {
if (fm.backStackEntryCount == 0)
return false
val reverseOrder = fm.fragments.filter { it is OnBackPressed }.reversed()
for (f in reverseOrder) {
val handledByChildFragments = recursivelyDispatchOnBackPressed(f.childFragmentManager)
if (handledByChildFragments) {
return true
}
val backPressable = f as OnBackPressed
if (backPressable.onBackPressed()) {
return true
}
}
return false
}
companion object {
fun newIntent(context: Context): Intent {
return Intent(context, HomeActivity::class.java)
}
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.features.home.room.list.RoomSelectionRepository
import io.reactivex.rxkotlin.subscribeBy
import org.koin.android.ext.android.get
class EmptyState : MvRxState
class HomeActivityViewModel(state: EmptyState,
private val session: Session,
roomSelectionRepository: RoomSelectionRepository
) : RiotViewModel<EmptyState>(state) {
companion object : MvRxViewModelFactory<HomeActivityViewModel, EmptyState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: EmptyState): HomeActivityViewModel? {
val session = Matrix.getInstance().currentSession!!
val roomSelectionRepository = viewModelContext.activity.get<RoomSelectionRepository>()
return HomeActivityViewModel(state, session, roomSelectionRepository)
}
}
private val _openRoomLiveData = MutableLiveData<LiveEvent<String>>()
val openRoomLiveData: LiveData<LiveEvent<String>>
get() = _openRoomLiveData
init {
val lastSelectedRoomId = roomSelectionRepository.lastSelectedRoom()
if (lastSelectedRoomId == null || session.getRoom(lastSelectedRoomId) == null) {
getTheFirstRoomWhenAvailable()
} else {
_openRoomLiveData.postValue(LiveEvent(lastSelectedRoomId))
}
}
private fun getTheFirstRoomWhenAvailable() {
session.rx().liveRoomSummaries()
.filter { it.isNotEmpty() }
.first(emptyList())
.subscribeBy {
val firstRoom = it.firstOrNull()
if (firstRoom != null) {
_openRoomLiveData.postValue(LiveEvent(firstRoom.roomId))
}
}
.disposeOnClear()
}
}

View File

@ -0,0 +1,52 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.replaceChildFragment
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.features.home.group.GroupListFragment
import im.vector.riotredesign.features.home.room.list.RoomListFragment
class HomeDrawerFragment : RiotFragment() {
companion object {
fun newInstance(): HomeDrawerFragment {
return HomeDrawerFragment()
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_home_drawer, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (savedInstanceState == null) {
val groupListFragment = GroupListFragment.newInstance()
replaceChildFragment(groupListFragment, R.id.groupListFragmentContainer)
val roomListFragment = RoomListFragment.newInstance()
replaceChildFragment(roomListFragment, R.id.roomListFragmentContainer)
}
}
}

View File

@ -0,0 +1,80 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home
import androidx.fragment.app.Fragment
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.features.home.group.GroupSummaryController
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.factory.*
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.list.RoomSummaryController
import im.vector.riotredesign.features.html.EventHtmlRenderer
import org.koin.dsl.module.module
class HomeModule {
companion object {
const val HOME_SCOPE = "HOME_SCOPE"
const val ROOM_DETAIL_SCOPE = "ROOM_DETAIL_SCOPE"
const val ROOM_LIST_SCOPE = "ROOM_LIST_SCOPE"
const val GROUP_LIST_SCOPE = "GROUP_LIST_SCOPE"
}
val definition = module {
// Activity scope
scope(HOME_SCOPE) {
HomeNavigator()
}
scope(HOME_SCOPE) {
HomePermalinkHandler(get())
}
// Fragment scopes
factory { (fragment: Fragment) ->
val eventHtmlRenderer = EventHtmlRenderer(GlideApp.with(fragment), fragment.requireContext(), get())
val timelineDateFormatter = TimelineDateFormatter(get())
val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val messageItemFactory = MessageItemFactory(get(), timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer)
val timelineItemFactory = TimelineItemFactory(messageItemFactory = messageItemFactory,
roomNameItemFactory = RoomNameItemFactory(get()),
roomTopicItemFactory = RoomTopicItemFactory(get()),
roomMemberItemFactory = RoomMemberItemFactory(get()),
roomHistoryVisibilityItemFactory = RoomHistoryVisibilityItemFactory(get()),
callItemFactory = CallItemFactory(get()),
defaultItemFactory = DefaultItemFactory()
)
TimelineEventController(timelineDateFormatter, timelineItemFactory, timelineMediaSizeProvider)
}
factory {
RoomSummaryController(get())
}
factory {
GroupSummaryController()
}
}
}

View File

@ -0,0 +1,75 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home
import androidx.core.view.GravityCompat
import androidx.fragment.app.FragmentManager
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.addFragmentToBackstack
import im.vector.riotredesign.core.extensions.replaceFragment
import im.vector.riotredesign.features.home.room.detail.RoomDetailArgs
import im.vector.riotredesign.features.home.room.detail.RoomDetailFragment
import kotlinx.android.synthetic.main.activity_home.*
import timber.log.Timber
class HomeNavigator {
var activity: HomeActivity? = null
private var rootRoomId: String? = null
fun openRoomDetail(roomId: String,
eventId: String?,
addToBackstack: Boolean = false) {
Timber.v("Open room detail $roomId - $eventId - $addToBackstack")
activity?.let {
//TODO enable eventId permalink. It doesn't work enough at the moment.
val args = RoomDetailArgs(roomId)
val roomDetailFragment = RoomDetailFragment.newInstance(args)
it.drawerLayout?.closeDrawer(GravityCompat.START)
if (addToBackstack) {
it.addFragmentToBackstack(roomDetailFragment, R.id.homeDetailFragmentContainer, roomId)
} else {
rootRoomId = roomId
clearBackStack(it.supportFragmentManager)
it.replaceFragment(roomDetailFragment, R.id.homeDetailFragmentContainer)
}
}
}
fun openGroupDetail(groupId: String) {
Timber.v("Open group detail $groupId")
}
fun openUserDetail(userId: String) {
Timber.v("Open user detail $userId")
}
// Private Methods *****************************************************************************
private fun clearBackStack(fragmentManager: FragmentManager) {
if (fragmentManager.backStackEntryCount > 0) {
val first = fragmentManager.getBackStackEntryAt(0)
fragmentManager.popBackStack(first.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
}
private fun isRoot(roomId: String): Boolean {
return rootRoomId == roomId
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home
import android.net.Uri
import im.vector.matrix.android.api.permalinks.PermalinkData
import im.vector.matrix.android.api.permalinks.PermalinkParser
class HomePermalinkHandler(private val navigator: HomeNavigator) {
fun launch(deepLink: String?) {
val uri = deepLink?.let { Uri.parse(it) }
launch(uri)
}
fun launch(deepLink: Uri?) {
if (deepLink == null) {
return
}
val permalinkData = PermalinkParser.parse(deepLink)
when (permalinkData) {
is PermalinkData.EventLink -> {
navigator.openRoomDetail(permalinkData.roomIdOrAlias, permalinkData.eventId, true)
}
is PermalinkData.RoomLink -> {
navigator.openRoomDetail(permalinkData.roomIdOrAlias, null, true)
}
is PermalinkData.GroupLink -> {
navigator.openGroupDetail(permalinkData.groupId)
}
is PermalinkData.UserLink -> {
navigator.openUserDetail(permalinkData.userId)
}
is PermalinkData.FallbackLink -> {
}
}
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.group
import im.vector.matrix.android.api.session.group.model.GroupSummary
sealed class GroupListActions {
data class SelectGroup(val groupSummary: GroupSummary) : GroupListActions()
}

View File

@ -0,0 +1,80 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.group
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.StateView
import im.vector.riotredesign.features.home.HomeModule
import kotlinx.android.synthetic.main.fragment_group_list.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
class GroupListFragment : RiotFragment(), GroupSummaryController.Callback {
companion object {
fun newInstance(): GroupListFragment {
return GroupListFragment()
}
}
private val viewModel: GroupListViewModel by fragmentViewModel()
private val groupController by inject<GroupSummaryController>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_group_list, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
bindScope(getOrCreateScope(HomeModule.GROUP_LIST_SCOPE))
groupController.callback = this
stateView.contentView = epoxyRecyclerView
epoxyRecyclerView.setController(groupController)
viewModel.subscribe { renderState(it) }
}
private fun renderState(state: GroupListViewState) {
when (state.asyncGroups) {
is Incomplete -> renderLoading()
is Success -> renderSuccess(state)
}
}
private fun renderSuccess(state: GroupListViewState) {
stateView.state = StateView.State.Content
groupController.setData(state)
}
private fun renderLoading() {
stateView.state = StateView.State.Loading
}
override fun onGroupSelected(groupSummary: GroupSummary) {
viewModel.accept(GroupListActions.SelectGroup(groupSummary))
}
}

View File

@ -0,0 +1,80 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.group
import arrow.core.Option
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel
import org.koin.android.ext.android.get
class GroupListViewModel(initialState: GroupListViewState,
private val selectedGroupHolder: SelectedGroupStore,
private val session: Session
) : RiotViewModel<GroupListViewState>(initialState) {
companion object : MvRxViewModelFactory<GroupListViewModel, GroupListViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: GroupListViewState): GroupListViewModel? {
val currentSession = viewModelContext.activity.get<Session>()
val selectedGroupHolder = viewModelContext.activity.get<SelectedGroupStore>()
return GroupListViewModel(state, selectedGroupHolder, currentSession)
}
}
init {
observeGroupSummaries()
observeState()
}
private fun observeState() {
subscribe {
val selectedGroup = Option.fromNullable(it.selectedGroup)
selectedGroupHolder.post(selectedGroup)
}
}
fun accept(action: GroupListActions) {
when (action) {
is GroupListActions.SelectGroup -> handleSelectGroup(action)
}
}
// PRIVATE METHODS *****************************************************************************
private fun handleSelectGroup(action: GroupListActions.SelectGroup) = withState { state ->
if (state.selectedGroup?.groupId != action.groupSummary.groupId) {
setState { copy(selectedGroup = action.groupSummary) }
} else {
setState { copy(selectedGroup = null) }
}
}
private fun observeGroupSummaries() {
session
.rx().liveGroupSummaries()
.execute { async ->
copy(asyncGroups = async)
}
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.group
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.group.model.GroupSummary
data class GroupListViewState(
val asyncGroups: Async<List<GroupSummary>> = Uninitialized,
val selectedGroup: GroupSummary? = null
) : MvRxState

View File

@ -0,0 +1,50 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.group
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.group.model.GroupSummary
class GroupSummaryController : TypedEpoxyController<GroupListViewState>() {
var callback: Callback? = null
override fun buildModels(viewState: GroupListViewState) {
buildGroupModels(viewState.asyncGroups(), viewState.selectedGroup)
}
private fun buildGroupModels(summaries: List<GroupSummary>?, selected: GroupSummary?) {
if (summaries.isNullOrEmpty()) {
return
}
summaries.forEach { groupSummary ->
val isSelected = groupSummary.groupId == selected?.groupId
groupSummaryItem {
id(groupSummary.groupId)
groupName(groupSummary.displayName)
selected(isSelected)
avatarUrl(groupSummary.avatarUrl)
listener { callback?.onGroupSelected(groupSummary) }
}
}
}
interface Callback {
fun onGroupSelected(groupSummary: GroupSummary)
}
}

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.group
import android.widget.ImageView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyHolder
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.core.platform.CheckableFrameLayout
import im.vector.riotredesign.features.home.AvatarRenderer
@EpoxyModelClass(layout = R.layout.item_group)
abstract class GroupSummaryItem : RiotEpoxyModel<GroupSummaryItem.Holder>() {
@EpoxyAttribute lateinit var groupName: CharSequence
@EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var selected: Boolean = false
@EpoxyAttribute var listener: (() -> Unit)? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.rootView.isSelected = selected
holder.rootView.setOnClickListener { listener?.invoke() }
AvatarRenderer.render(avatarUrl, groupName.toString(), holder.avatarImageView)
}
class Holder : RiotEpoxyHolder() {
val avatarImageView by bind<ImageView>(R.id.groupAvatarImageView)
val rootView by bind<CheckableFrameLayout>(R.id.itemGroupLayout)
}
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.group
import arrow.core.Option
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.riotredesign.core.utils.RxStore
class SelectedGroupStore : RxStore<Option<GroupSummary>>(Option.empty())

View File

@ -0,0 +1,21 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room
import im.vector.riotredesign.core.utils.RxStore
class VisibleRoomStore : RxStore<String>()

View File

@ -0,0 +1,49 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.bumptech.glide.Glide
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.RiotFragment
import kotlinx.android.synthetic.main.fragment_loading_room_detail.*
class LoadingRoomDetailFragment : RiotFragment() {
companion object {
fun newInstance(): LoadingRoomDetailFragment {
return LoadingRoomDetailFragment()
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_loading_room_detail, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Glide.with(this)
.load(R.drawable.riot_splash)
.into(animatedLogoImageView)
}
}

View File

@ -0,0 +1,29 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
sealed class RoomDetailActions {
data class SendMessage(val text: String) : RoomDetailActions()
object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()
}

View File

@ -0,0 +1,164 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomePermalinkHandler
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.media.MediaContentRenderer
import im.vector.riotredesign.features.media.MediaViewerActivity
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf
@Parcelize
data class RoomDetailArgs(
val roomId: String,
val eventId: String? = null
) : Parcelable
class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
companion object {
fun newInstance(args: RoomDetailArgs): RoomDetailFragment {
return RoomDetailFragment().apply {
setArguments(args)
}
}
}
private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
private val timelineEventController: TimelineEventController by inject { parametersOf(this) }
private val homePermalinkHandler: HomePermalinkHandler by inject()
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_room_detail, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
bindScope(getOrCreateScope(HomeModule.ROOM_DETAIL_SCOPE))
setupRecyclerView()
setupToolbar()
setupSendButton()
roomDetailViewModel.subscribe { renderState(it) }
}
override fun onResume() {
super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
}
// PRIVATE METHODS *****************************************************************************
private fun setupToolbar() {
val parentActivity = riotActivity
if (parentActivity is ToolbarConfigurable) {
parentActivity.configure(toolbar)
}
}
private fun setupRecyclerView() {
val epoxyVisibilityTracker = EpoxyVisibilityTracker()
epoxyVisibilityTracker.attach(recyclerView)
val layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true)
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager)
recyclerView.layoutManager = layoutManager
recyclerView.itemAnimator = null
recyclerView.setHasFixedSize(true)
timelineEventController.addModelBuildListener {
it.dispatchTo(stateRestorer)
it.dispatchTo(scrollOnNewMessageCallback)
}
recyclerView.addOnScrollListener(
EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction ->
roomDetailViewModel.process(RoomDetailActions.LoadMore(direction))
})
recyclerView.setController(timelineEventController)
timelineEventController.callback = this
}
private fun setupSendButton() {
sendButton.setOnClickListener {
val textMessage = composerEditText.text.toString()
if (textMessage.isNotBlank()) {
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage))
composerEditText.text = null
}
}
}
private fun renderState(state: RoomDetailViewState) {
renderRoomSummary(state)
timelineEventController.setTimeline(state.timeline)
}
private fun renderRoomSummary(state: RoomDetailViewState) {
state.asyncRoomSummary()?.let {
toolbarTitleView.text = it.displayName
AvatarRenderer.render(it, toolbarAvatarImageView)
if (it.topic.isNotEmpty()) {
toolbarSubtitleView.visibility = View.VISIBLE
toolbarSubtitleView.text = it.topic
} else {
toolbarSubtitleView.visibility = View.GONE
}
}
}
// TimelineEventController.Callback ************************************************************
override fun onUrlClicked(url: String) {
homePermalinkHandler.launch(url)
}
override fun onEventVisible(event: TimelineEvent) {
roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event))
}
override fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View) {
val intent = MediaViewerActivity.newIntent(riotActivity, mediaData)
startActivity(intent)
}
}

View File

@ -0,0 +1,117 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import io.reactivex.rxkotlin.subscribeBy
import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit
class RoomDetailViewModel(initialState: RoomDetailViewState,
private val session: Session,
private val visibleRoomHolder: VisibleRoomStore
) : RiotViewModel<RoomDetailViewState>(initialState) {
private val room = session.getRoom(initialState.roomId)!!
private val roomId = initialState.roomId
private val eventId = initialState.eventId
private val displayedEventsObservable = BehaviorRelay.create<RoomDetailActions.EventDisplayed>()
private val timeline = room.createTimeline(eventId, TimelineDisplayableEvents.DISPLAYABLE_TYPES)
companion object : MvRxViewModelFactory<RoomDetailViewModel, RoomDetailViewState> {
const val PAGINATION_COUNT = 50
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel? {
val currentSession = viewModelContext.activity.get<Session>()
val visibleRoomHolder = viewModelContext.activity.get<VisibleRoomStore>()
return RoomDetailViewModel(state, currentSession, visibleRoomHolder)
}
}
init {
observeRoomSummary()
observeEventDisplayedActions()
room.loadRoomMembersIfNeeded()
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
}
fun process(action: RoomDetailActions) {
when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
}
}
// PRIVATE METHODS *****************************************************************************
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {})
}
private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {
displayedEventsObservable.accept(action)
}
private fun handleIsDisplayed() {
visibleRoomHolder.post(roomId)
}
private fun handleLoadMore(action: RoomDetailActions.LoadMore) {
timeline.paginate(action.direction, PAGINATION_COUNT)
}
private fun observeEventDisplayedActions() {
// We are buffering scroll events for one second
// and keep the most recent one to set the read receipt on.
displayedEventsObservable
.buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions ->
val mostRecentEvent = actions.maxBy { it.event.displayIndex }
mostRecentEvent?.event?.root?.eventId?.let { eventId ->
room.setReadReceipt(eventId, callback = object : MatrixCallback<Void> {})
}
})
.disposeOnClear()
}
private fun observeRoomSummary() {
room.rx().liveRoomSummary()
.execute { async ->
copy(asyncRoomSummary = async)
}
}
override fun onCleared() {
timeline.dispose()
super.onCleared()
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineData
data class RoomDetailViewState(
val roomId: String,
val eventId: String?,
val timeline: Timeline? = null,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val asyncTimelineData: Async<TimelineData> = Uninitialized
) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)
}

View File

@ -0,0 +1,30 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail
import androidx.recyclerview.widget.LinearLayoutManager
import im.vector.riotredesign.core.platform.DefaultListUpdateCallback
class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager) : DefaultListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0) {
layoutManager.scrollToPosition(0)
}
}
}

View File

@ -0,0 +1,201 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline
import android.os.Handler
import android.os.Looper
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.VisibilityState
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.LoadingItemModel_
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.*
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.riotredesign.features.media.MediaContentRenderer
class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val backgroundHandler: Handler = TimelineAsyncHelper.getBackgroundHandler()
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener {
interface Callback {
fun onEventVisible(event: TimelineEvent)
fun onUrlClicked(url: String)
fun onMediaClicked(mediaData: MediaContentRenderer.Data, view: View)
}
private val modelCache = arrayListOf<List<EpoxyModel<*>>>()
private var currentSnapshot: List<TimelineEvent> = emptyList()
private var inSubmitList: Boolean = false
private var timeline: Timeline? = null
var callback: Callback? = null
private val listUpdateCallback = object : ListUpdateCallback {
@Synchronized
override fun onChanged(position: Int, count: Int, payload: Any?) {
assertUpdateCallbacksAllowed()
(position until (position + count)).forEach {
modelCache[it] = emptyList()
}
requestModelBuild()
}
@Synchronized
override fun onMoved(fromPosition: Int, toPosition: Int) {
assertUpdateCallbacksAllowed()
val model = modelCache.removeAt(fromPosition)
modelCache.add(toPosition, model)
requestModelBuild()
}
@Synchronized
override fun onInserted(position: Int, count: Int) {
assertUpdateCallbacksAllowed()
if (modelCache.isNotEmpty() && position == modelCache.size) {
modelCache[position - 1] = emptyList()
}
(0 until count).forEach {
modelCache.add(position, emptyList())
}
requestModelBuild()
}
@Synchronized
override fun onRemoved(position: Int, count: Int) {
assertUpdateCallbacksAllowed()
(0 until count).forEach {
modelCache.removeAt(position)
}
requestModelBuild()
}
}
init {
requestModelBuild()
}
fun setTimeline(timeline: Timeline?) {
if (this.timeline != timeline) {
this.timeline = timeline
this.timeline?.listener = this
}
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
timelineMediaSizeProvider.recyclerView = recyclerView
}
override fun buildModels() {
LoadingItemModel_()
.id("forward_loading_item")
.addWhen(Timeline.Direction.FORWARDS)
val timelineModels = getModels()
add(timelineModels)
LoadingItemModel_()
.id("backward_loading_item")
.addWhen(Timeline.Direction.BACKWARDS)
}
// Timeline.LISTENER ***************************************************************************
override fun onUpdated(snapshot: List<TimelineEvent>) {
submitSnapshot(snapshot)
}
private fun submitSnapshot(newSnapshot: List<TimelineEvent>) {
backgroundHandler.post {
inSubmitList = true
val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot)
currentSnapshot = newSnapshot
val diffResult = DiffUtil.calculateDiff(diffCallback)
diffResult.dispatchUpdatesTo(listUpdateCallback)
inSubmitList = false
}
}
private fun assertUpdateCallbacksAllowed() {
require(inSubmitList || Looper.myLooper() == backgroundHandler.looper)
}
@Synchronized
private fun getModels(): List<EpoxyModel<*>> {
(0 until modelCache.size).forEach { position ->
if (modelCache[position].isEmpty()) {
modelCache[position] = buildItemModels(position, currentSnapshot)
}
}
return modelCache.flatten()
}
private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): List<EpoxyModel<*>> {
val epoxyModels = ArrayList<EpoxyModel<*>>()
val event = items[currentPosition]
val nextEvent = items.nextDisplayableEvent(currentPosition)
val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
timelineItemFactory.create(event, nextEvent, callback).also {
it.id(event.localId)
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
epoxyModels.add(it)
}
if (addDaySeparator) {
val formattedDay = dateFormatter.formatMessageDay(date)
val daySeparatorItem = DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay)
epoxyModels.add(daySeparatorItem)
}
return epoxyModels
}
private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) {
val shouldAdd = timeline?.let {
it.hasMoreToLoad(direction)
} ?: false
addIf(shouldAdd, this@TimelineEventController)
}
}
private class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?,
private val event: TimelineEvent)
: RiotEpoxyModel.OnVisibilityStateChangedListener {
override fun onVisibilityStateChanged(visibilityState: Int) {
if (visibilityState == VisibilityState.VISIBLE) {
callback?.onEventVisible(event)
}
}
}

View File

@ -0,0 +1,32 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.animation
import androidx.recyclerview.widget.DefaultItemAnimator
private const val ANIM_DURATION_IN_MILLIS = 100L
class TimelineItemAnimator : DefaultItemAnimator() {
init {
addDuration = ANIM_DURATION_IN_MILLIS
removeDuration = 0
moveDuration = 0
changeDuration = 0
}
}

View File

@ -0,0 +1,60 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
class CallItemFactory(private val stringProvider: StringProvider) {
fun create(event: TimelineEvent): NoticeItem? {
val roomMember = event.roomMember ?: return null
val text = buildNoticeText(event.root, roomMember) ?: return null
return NoticeItem_()
.noticeText(text)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
}
private fun buildNoticeText(event: Event, roomMember: RoomMember): CharSequence? {
return when {
EventType.CALL_INVITE == event.type -> {
val content = event.content.toModel<CallInviteContent>() ?: return null
val isVideoCall = content.offer.sdp == CallInviteContent.Offer.SDP_VIDEO
return if (isVideoCall) {
stringProvider.getString(R.string.notice_placed_video_call, roomMember.displayName)
} else {
stringProvider.getString(R.string.notice_placed_voice_call, roomMember.displayName)
}
}
EventType.CALL_ANSWER == event.type -> stringProvider.getString(R.string.notice_answered_call, roomMember.displayName)
EventType.CALL_HANGUP == event.type -> stringProvider.getString(R.string.notice_ended_call, roomMember.displayName)
else -> null
}
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem_
class DefaultItemFactory {
fun create(event: TimelineEvent, exception: Exception? = null): DefaultItem? {
val text = if (exception == null) {
"${event.root.type} events are not yet handled"
} else {
"an exception occurred when rendering the event ${event.root.eventId}"
}
return DefaultItem_().text(text)
}
}

View File

@ -0,0 +1,162 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.util.Linkify
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.*
import im.vector.riotredesign.features.html.EventHtmlRenderer
import im.vector.riotredesign.features.media.MediaContentRenderer
import me.gujun.android.span.span
class MessageItemFactory(private val colorProvider: ColorProvider,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val timelineDateFormatter: TimelineDateFormatter,
private val htmlRenderer: EventHtmlRenderer) {
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
callback: TimelineEventController.Callback?
): RiotEpoxyModel<*>? {
val roomMember = event.roomMember
val nextRoomMember = nextEvent?.roomMember
val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false
val showInformation = addDaySeparator
|| nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo
val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = roomMember?.avatarUrl
val memberName = roomMember?.displayName ?: event.root.sender
val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation)
return when (messageContent) {
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback)
else -> buildNotHandledMessageItem(messageContent)
}
}
private fun buildNotHandledMessageItem(messageContent: MessageContent): DefaultItem? {
val text = "${messageContent.type} message events are not yet handled"
return DefaultItem_().text(text)
}
private fun buildImageMessageItem(messageContent: MessageImageContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageItem? {
val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val data = MediaContentRenderer.Data(
messageContent.body,
url = messageContent.url,
height = messageContent.info?.height,
maxHeight = maxHeight,
width = messageContent.info?.width,
maxWidth = maxWidth,
rotation = messageContent.info?.rotation,
orientation = messageContent.info?.orientation
)
return MessageImageItem_()
.informationData(informationData)
.mediaData(data)
.clickListener { view -> callback?.onMediaClicked(data, view) }
}
private fun buildTextMessageItem(messageContent: MessageTextContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {
val bodyToUse = messageContent.formattedBody
?.let {
htmlRenderer.render(it)
}
?: messageContent.body
val linkifiedBody = linkifyBody(bodyToUse, callback)
return MessageTextItem_()
.message(linkifiedBody)
.informationData(informationData)
}
private fun buildNoticeMessageItem(messageContent: MessageNoticeContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {
val message = messageContent.body.let {
val formattedBody = span {
text = it
textColor = colorProvider.getColor(R.color.slate_grey)
textStyle = "italic"
}
linkifyBody(formattedBody, callback)
}
return MessageTextItem_()
.message(message)
.informationData(informationData)
}
private fun buildEmoteMessageItem(messageContent: MessageEmoteContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {
val message = messageContent.body.let {
val formattedBody = "* ${informationData.memberName} $it"
linkifyBody(formattedBody, callback)
}
return MessageTextItem_()
.message(message)
.informationData(informationData)
}
private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): Spannable {
val spannable = SpannableStringBuilder(body)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
override fun onUrlClicked(url: String) {
callback?.onUrlClicked(url)
}
})
Linkify.addLinks(spannable, Linkify.ALL)
return spannable
}
}

View File

@ -0,0 +1,56 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibilityContent
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
class RoomHistoryVisibilityItemFactory(private val stringProvider: StringProvider) {
fun create(event: TimelineEvent): NoticeItem? {
val roomMember = event.roomMember ?: return null
val noticeText = buildNoticeText(event.root, roomMember) ?: return null
return NoticeItem_()
.noticeText(noticeText)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
}
private fun buildNoticeText(event: Event, roomMember: RoomMember): CharSequence? {
val content = event.content.toModel<RoomHistoryVisibilityContent>() ?: return null
val formattedVisibility = when (content.historyVisibility) {
RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared)
RoomHistoryVisibility.INVITED -> stringProvider.getString(R.string.notice_room_visibility_invited)
RoomHistoryVisibility.JOINED -> stringProvider.getString(R.string.notice_room_visibility_joined)
RoomHistoryVisibility.WORLD_READABLE -> stringProvider.getString(R.string.notice_room_visibility_world_readable)
}
return stringProvider.getString(R.string.notice_made_future_room_visibility, roomMember.displayName, formattedVisibility)
}
}

View File

@ -0,0 +1,130 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import android.text.TextUtils
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
//TODO : complete with call membership events¬
class RoomMemberItemFactory(private val stringProvider: StringProvider) {
fun create(event: TimelineEvent): NoticeItem? {
val roomMember = event.roomMember ?: return null
val noticeText = buildRoomMemberNotice(event) ?: return null
return NoticeItem_()
.noticeText(noticeText)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
}
private fun buildRoomMemberNotice(event: TimelineEvent): String? {
val eventContent: RoomMember? = event.root.content.toModel()
val prevEventContent: RoomMember? = event.root.prevContent.toModel()
val isMembershipEvent = prevEventContent?.membership != eventContent?.membership
return if (isMembershipEvent) {
buildMembershipNotice(event, eventContent, prevEventContent)
} else {
buildProfileNotice(event, eventContent, prevEventContent)
}
}
private fun buildProfileNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val displayText = StringBuilder()
// Check display name has been changed
if (!TextUtils.equals(eventContent?.displayName, prevEventContent?.displayName)) {
val displayNameText = when {
prevEventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_set, event.root.sender, eventContent?.displayName)
eventContent?.displayName.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_display_name_removed, event.root.sender, prevEventContent?.displayName)
else ->
stringProvider.getString(R.string.notice_display_name_changed_from,
event.root.sender, prevEventContent?.displayName, eventContent?.displayName)
}
displayText.append(displayNameText)
}
// Check whether the avatar has been changed
if (!TextUtils.equals(eventContent?.avatarUrl, prevEventContent?.avatarUrl)) {
val displayAvatarText = if (displayText.isNotEmpty()) {
displayText.append(" ")
stringProvider.getString(R.string.notice_avatar_changed_too)
} else {
stringProvider.getString(R.string.notice_avatar_url_changed, event.roomMember?.displayName)
}
displayText.append(displayAvatarText)
}
return displayText.toString()
}
private fun buildMembershipNotice(event: TimelineEvent, eventContent: RoomMember?, prevEventContent: RoomMember?): String? {
val senderDisplayName = event.roomMember?.displayName ?: return null
val targetDisplayName = eventContent?.displayName ?: event.root.sender
return when {
Membership.INVITE == eventContent?.membership -> {
// TODO get userId
val selfUserId: String = ""
when {
eventContent.thirdPartyInvite != null ->
stringProvider.getString(R.string.notice_room_third_party_registered_invite,
targetDisplayName, eventContent.thirdPartyInvite?.displayName)
TextUtils.equals(event.root.stateKey, selfUserId) ->
stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName)
event.root.stateKey.isNullOrEmpty() ->
stringProvider.getString(R.string.notice_room_invite_no_invitee, senderDisplayName)
else ->
stringProvider.getString(R.string.notice_room_invite, senderDisplayName, targetDisplayName)
}
}
Membership.JOIN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_join, senderDisplayName)
Membership.LEAVE == eventContent?.membership ->
// 2 cases here: this member may have left voluntarily or they may have been "left" by someone else ie. kicked
return if (TextUtils.equals(event.root.sender, event.root.stateKey)) {
if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_reject, senderDisplayName)
} else {
stringProvider.getString(R.string.notice_room_leave, senderDisplayName)
}
} else if (prevEventContent?.membership == Membership.INVITE) {
stringProvider.getString(R.string.notice_room_withdraw, senderDisplayName, targetDisplayName)
} else if (prevEventContent?.membership == Membership.JOIN) {
stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName)
} else if (prevEventContent?.membership == Membership.BAN) {
stringProvider.getString(R.string.notice_room_unban, senderDisplayName, targetDisplayName)
} else {
null
}
Membership.BAN == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_ban, senderDisplayName, targetDisplayName)
Membership.KNOCK == eventContent?.membership ->
stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName)
else -> null
}
}
}

View File

@ -0,0 +1,49 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import android.text.TextUtils
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomNameContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
class RoomNameItemFactory(private val stringProvider: StringProvider) {
fun create(event: TimelineEvent): NoticeItem? {
val content: RoomNameContent? = event.root.content.toModel()
val roomMember = event.roomMember
if (content == null || roomMember == null) {
return null
}
val text = if (!TextUtils.isEmpty(content.name)) {
stringProvider.getString(R.string.notice_room_name_changed, roomMember.displayName, content.name)
} else {
stringProvider.getString(R.string.notice_room_name_removed, roomMember.displayName)
}
return NoticeItem_()
.noticeText(text)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
}
}

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomTopicContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
class RoomTopicItemFactory(private val stringProvider: StringProvider) {
fun create(event: TimelineEvent): NoticeItem? {
val content: RoomTopicContent? = event.root.content.toModel()
val roomMember = event.roomMember
if (content == null || roomMember == null) {
return null
}
val text = if (content.topic.isNullOrEmpty()) {
stringProvider.getString(R.string.notice_room_topic_removed, roomMember.displayName)
} else {
stringProvider.getString(R.string.notice_room_topic_changed, roomMember.displayName, content.topic)
}
return NoticeItem_()
.noticeText(text)
.avatarUrl(roomMember.avatarUrl)
.memberName(roomMember.displayName)
}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.EmptyItem_
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
private val roomNameItemFactory: RoomNameItemFactory,
private val roomTopicItemFactory: RoomTopicItemFactory,
private val roomMemberItemFactory: RoomMemberItemFactory,
private val roomHistoryVisibilityItemFactory: RoomHistoryVisibilityItemFactory,
private val callItemFactory: CallItemFactory,
private val defaultItemFactory: DefaultItemFactory) {
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
callback: TimelineEventController.Callback?): RiotEpoxyModel<*> {
val computedModel = try {
when (event.root.type) {
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, callback)
EventType.STATE_ROOM_NAME -> roomNameItemFactory.create(event)
EventType.STATE_ROOM_TOPIC -> roomTopicItemFactory.create(event)
EventType.STATE_ROOM_MEMBER -> roomMemberItemFactory.create(event)
EventType.STATE_HISTORY_VISIBILITY -> roomHistoryVisibilityItemFactory.create(event)
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> callItemFactory.create(event)
EventType.ENCRYPTED,
EventType.ENCRYPTION,
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER,
EventType.STATE_ROOM_CREATE -> defaultItemFactory.create(event)
else -> null
}
} catch (e: Exception) {
defaultItemFactory.create(event, e)
}
return computedModel ?: EmptyItem_()
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import im.vector.matrix.android.api.session.room.timeline.Timeline
class EndlessRecyclerViewScrollListener(private val layoutManager: LinearLayoutManager,
private val visibleThreshold: Int,
private val onLoadMore: (Timeline.Direction) -> Unit
) : RecyclerView.OnScrollListener() {
// The total number of items in the dataset after the last load
private var previousTotalItemCount = 0
// True if we are still waiting for the last set of data to load.
private var loadingBackwards = true
private var loadingForwards = true
// This happens many times a second during a scroll, so be wary of the code you place here.
// We are given a few useful parameters to help us work out if we need to load some more data,
// but first we check if we are waiting for the previous load to finish.
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val totalItemCount = layoutManager.itemCount
// The minimum amount of items to have below your current scroll position
// before loading more.
// If the total item count is zero and the previous isn't, assume the
// list is invalidated and should be reset back to initial state
if (totalItemCount < previousTotalItemCount) {
previousTotalItemCount = totalItemCount
if (totalItemCount == 0) {
loadingForwards = true
loadingBackwards = true
}
}
// If its still loading, we check to see if the dataset count has
// changed, if so we conclude it has finished loading
if (totalItemCount > previousTotalItemCount) {
loadingBackwards = false
loadingForwards = false
previousTotalItemCount = totalItemCount
}
// If it isnt currently loading, we check to see if we have reached
// the visibleThreshold and need to reload more data.
if (!loadingBackwards && lastVisibleItemPosition + visibleThreshold > totalItemCount) {
loadingBackwards = true
onLoadMore(Timeline.Direction.BACKWARDS)
}
if (!loadingForwards && firstVisibleItemPosition < visibleThreshold) {
loadingForwards = true
onLoadMore(Timeline.Direction.FORWARDS)
}
}
}

View File

@ -0,0 +1,40 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import android.os.Handler
import android.os.HandlerThread
private const val THREAD_NAME = "Timeline_Building_Thread"
object TimelineAsyncHelper {
private var backgroundHandler: Handler? = null
fun getBackgroundHandler(): Handler {
return backgroundHandler ?: createBackgroundHandler().also { backgroundHandler = it }
}
private fun createBackgroundHandler(): Handler {
val handlerThread = HandlerThread(THREAD_NAME)
handlerThread.start()
return Handler(handlerThread.looper)
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import im.vector.riotredesign.core.resources.LocaleProvider
import org.threeten.bp.LocalDateTime
import org.threeten.bp.format.DateTimeFormatter
class TimelineDateFormatter(private val localeProvider: LocaleProvider) {
private val messageHourFormatter by lazy {
DateTimeFormatter.ofPattern("H:mm", localeProvider.current())
}
private val messageDayFormatter by lazy {
DateTimeFormatter.ofPattern("EEE d MMM", localeProvider.current())
}
fun formatMessageHour(localDateTime: LocalDateTime): String {
return messageHourFormatter.format(localDateTime)
}
fun formatMessageDay(localDateTime: LocalDateTime): String {
return messageDayFormatter.format(localDateTime)
}
}

View File

@ -0,0 +1,57 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
object TimelineDisplayableEvents {
val DISPLAYABLE_TYPES = listOf(
EventType.MESSAGE,
EventType.STATE_ROOM_NAME,
EventType.STATE_ROOM_TOPIC,
EventType.STATE_ROOM_MEMBER,
EventType.STATE_HISTORY_VISIBILITY,
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER,
EventType.ENCRYPTED,
EventType.ENCRYPTION,
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER,
EventType.STATE_ROOM_CREATE
)
}
fun TimelineEvent.isDisplayable(): Boolean {
return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.type) && !root.content.isNullOrEmpty()
}
fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
return this.filter {
it.isDisplayable()
}
}
fun List<TimelineEvent>.nextDisplayableEvent(index: Int): TimelineEvent? {
return if (index == size - 1) {
null
} else {
subList(index + 1, this.size).firstOrNull { it.isDisplayable() }
}
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import androidx.recyclerview.widget.DiffUtil
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
class TimelineEventDiffUtilCallback(private val oldList: List<TimelineEvent>,
private val newList: List<TimelineEvent>) : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.localId == newItem.localId
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem == newItem
}
}

View File

@ -0,0 +1,47 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.helper
import androidx.recyclerview.widget.RecyclerView
class TimelineMediaSizeProvider {
lateinit var recyclerView: RecyclerView
private var cachedSize: Pair<Int, Int>? = null
fun getMaxSize(): Pair<Int, Int> {
return cachedSize ?: computeMaxSize().also { cachedSize = it }
}
private fun computeMaxSize(): Pair<Int, Int> {
val width = recyclerView.width
val height = recyclerView.height
val maxImageWidth: Int
val maxImageHeight: Int
// landscape / portrait
if (width < height) {
maxImageWidth = Math.round(width * 0.7f)
maxImageHeight = Math.round(height * 0.5f)
} else {
maxImageWidth = Math.round(width * 0.5f)
maxImageHeight = Math.round(height * 0.7f)
}
return Pair(maxImageWidth, maxImageHeight)
}
}

View File

@ -0,0 +1,51 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import im.vector.riotredesign.core.epoxy.RiotEpoxyHolder
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.features.home.AvatarRenderer
abstract class AbsMessageItem<H : AbsMessageItem.Holder> : RiotEpoxyModel<H>() {
abstract val informationData: MessageInformationData
override fun bind(holder: H) {
if (informationData.showInformation) {
holder.avatarImageView.visibility = View.VISIBLE
holder.memberNameView.visibility = View.VISIBLE
holder.timeView.visibility = View.VISIBLE
holder.timeView.text = informationData.time
holder.memberNameView.text = informationData.memberName
AvatarRenderer.render(informationData.avatarUrl, informationData.memberName?.toString(), holder.avatarImageView)
} else {
holder.avatarImageView.visibility = View.GONE
holder.memberNameView.visibility = View.GONE
holder.timeView.visibility = View.GONE
}
}
abstract class Holder : RiotEpoxyHolder() {
abstract val avatarImageView: ImageView
abstract val memberNameView: TextView
abstract val timeView: TextView
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyHolder
@EpoxyModelClass(layout = R.layout.item_timeline_event_day_separator)
abstract class DaySeparatorItem : EpoxyModelWithHolder<DaySeparatorItem.Holder>() {
@EpoxyAttribute lateinit var formattedDay: CharSequence
override fun bind(holder: Holder) {
holder.dayTextView.text = formattedDay
}
class Holder : RiotEpoxyHolder() {
val dayTextView by bind<TextView>(R.id.itemDayTextView)
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyHolder
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
@EpoxyModelClass(layout = R.layout.item_timeline_event_default)
abstract class DefaultItem : RiotEpoxyModel<DefaultItem.Holder>() {
@EpoxyAttribute var text: CharSequence? = null
override fun bind(holder: Holder) {
holder.messageView.text = text
}
class Holder : RiotEpoxyHolder() {
val messageView by bind<TextView>(R.id.stateMessageView)
}
}

View File

@ -0,0 +1,47 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.features.media.MediaContentRenderer
@EpoxyModelClass(layout = R.layout.item_timeline_event_image_message)
abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() {
@EpoxyAttribute lateinit var mediaData: MediaContentRenderer.Data
@EpoxyAttribute override lateinit var informationData: MessageInformationData
@EpoxyAttribute var clickListener: View.OnClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
MediaContentRenderer.render(mediaData, MediaContentRenderer.Mode.THUMBNAIL, holder.imageView)
holder.imageView.setOnClickListener(clickListener)
}
class Holder : AbsMessageItem.Holder() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
val imageView by bind<ImageView>(R.id.messageImageView)
}
}

View File

@ -0,0 +1,24 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
data class MessageInformationData(
val time: CharSequence? = null,
val avatarUrl: String?,
val memberName: CharSequence? = null,
val showInformation: Boolean = true
)

View File

@ -0,0 +1,70 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.text.Spannable
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.riotredesign.R
import im.vector.riotredesign.features.html.PillImageSpan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@EpoxyModelClass(layout = R.layout.item_timeline_event_text_message)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute var message: Spannable? = null
@EpoxyAttribute override lateinit var informationData: MessageInformationData
override fun bind(holder: Holder) {
super.bind(holder)
MatrixLinkify.addLinkMovementMethod(holder.messageView)
val textFuture = PrecomputedTextCompat.getTextFuture(message ?: "",
TextViewCompat.getTextMetricsParams(holder.messageView),
null)
holder.messageView.setTextFuture(textFuture)
findPillsAndProcess { it.bind(holder.messageView) }
}
private fun findPillsAndProcess(processBlock: (span: PillImageSpan) -> Unit) {
GlobalScope.launch(Dispatchers.Main) {
val pillImageSpans: Array<PillImageSpan>? = withContext(Dispatchers.IO) {
message?.let { spannable ->
spannable.getSpans(0, spannable.length, PillImageSpan::class.java)
}
}
pillImageSpans?.forEach { processBlock(it) }
}
}
class Holder : AbsMessageItem.Holder() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
val messageView by bind<AppCompatTextView>(R.id.messageTextView)
}
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyHolder
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.features.home.AvatarRenderer
@EpoxyModelClass(layout = R.layout.item_timeline_event_notice)
abstract class NoticeItem : RiotEpoxyModel<NoticeItem.Holder>() {
@EpoxyAttribute var noticeText: CharSequence? = null
@EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var memberName: CharSequence? = null
override fun bind(holder: Holder) {
holder.noticeTextView.text = noticeText
AvatarRenderer.render(avatarUrl, memberName?.toString(), holder.avatarImageView)
}
class Holder : RiotEpoxyHolder() {
val avatarImageView by bind<ImageView>(R.id.itemNoticeAvatarView)
val noticeTextView by bind<TextView>(R.id.itemNoticeTextView)
}
}

View File

@ -0,0 +1,58 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyHolder
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
@EpoxyModelClass(layout = R.layout.item_room_category)
abstract class RoomCategoryItem : RiotEpoxyModel<RoomCategoryItem.Holder>() {
@EpoxyAttribute lateinit var title: CharSequence
@EpoxyAttribute var expanded: Boolean = false
@EpoxyAttribute var unreadCount: Int = 0
@EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var listener: (() -> Unit)? = null
override fun bind(holder: Holder) {
val tintColor = ContextCompat.getColor(holder.rootView.context, R.color.bluey_grey_two)
val expandedArrowDrawableRes = if (expanded) R.drawable.ic_expand_more_white else R.drawable.ic_expand_less_white
val expandedArrowDrawable = ContextCompat.getDrawable(holder.rootView.context, expandedArrowDrawableRes)?.also {
DrawableCompat.setTint(it, tintColor)
}
holder.unreadCounterBadgeView.render(unreadCount, showHighlighted)
holder.titleView.setCompoundDrawablesWithIntrinsicBounds(expandedArrowDrawable, null, null, null)
holder.titleView.text = title
holder.rootView.setOnClickListener { listener?.invoke() }
}
class Holder : RiotEpoxyHolder() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomCategoryUnreadCounterBadgeView)
val titleView by bind<TextView>(R.id.roomCategoryTitleView)
val rootView by bind<ViewGroup>(R.id.roomCategoryRootView)
}
}

View File

@ -0,0 +1,29 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import im.vector.matrix.android.api.session.room.model.RoomSummary
sealed class RoomListActions {
data class SelectRoom(val roomSummary: RoomSummary) : RoomListActions()
data class FilterRooms(val roomName: CharSequence? = null) : RoomListActions()
data class ToggleCategory(val category: RoomCategory) : RoomListActions()
}

View File

@ -0,0 +1,133 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.extensions.setupAsSearch
import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.StateView
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomeNavigator
import kotlinx.android.synthetic.main.fragment_room_list.*
import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
class RoomListFragment : RiotFragment(), RoomSummaryController.Callback {
companion object {
fun newInstance(): RoomListFragment {
return RoomListFragment()
}
}
private val roomController by inject<RoomSummaryController>()
private val homeNavigator by inject<HomeNavigator>()
private val roomListViewModel: RoomListViewModel by fragmentViewModel()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_room_list, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
bindScope(getOrCreateScope(HomeModule.ROOM_LIST_SCOPE))
setupRecyclerView()
setupFilterView()
roomListViewModel.subscribe { renderState(it) }
roomListViewModel.openRoomLiveData.observeEvent(this) {
homeNavigator.openRoomDetail(it, null)
}
}
private fun setupRecyclerView() {
val layoutManager = LinearLayoutManager(context)
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
epoxyRecyclerView.layoutManager = layoutManager
roomController.callback = this
roomController.addModelBuildListener { it.dispatchTo(stateRestorer) }
stateView.contentView = epoxyRecyclerView
epoxyRecyclerView.setController(roomController)
}
private fun setupFilterView() {
filterRoomView.setupAsSearch()
filterRoomView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
roomListViewModel.accept(RoomListActions.FilterRooms(s))
}
})
}
private fun renderState(state: RoomListViewState) {
when (state.asyncRooms) {
is Incomplete -> renderLoading()
is Success -> renderSuccess(state)
is Fail -> renderFailure(state.asyncRooms.error)
}
}
private fun renderSuccess(state: RoomListViewState) {
if (state.asyncRooms().isNullOrEmpty()) {
stateView.state = StateView.State.Empty(getString(R.string.room_list_empty))
} else {
stateView.state = StateView.State.Content
}
roomController.setData(state)
}
private fun renderLoading() {
stateView.state = StateView.State.Loading
}
private fun renderFailure(error: Throwable) {
val message = when (error) {
is Failure.NetworkConnection -> getString(R.string.network_error_please_check_and_retry)
else -> getString(R.string.unknown_error)
}
stateView.state = StateView.State.Error(message)
}
// RoomSummaryController.Callback **************************************************************
override fun onRoomSelected(room: RoomSummary) {
roomListViewModel.accept(RoomListActions.SelectRoom(room))
}
override fun onToggleRoomCategory(roomCategory: RoomCategory) {
roomListViewModel.accept(RoomListActions.ToggleCategory(roomCategory))
}
}

View File

@ -0,0 +1,183 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import arrow.core.Option
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.group.model.GroupSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.features.home.group.SelectedGroupStore
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import io.reactivex.Observable
import io.reactivex.functions.Function3
import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit
typealias RoomListFilterName = CharSequence
class RoomListViewModel(initialState: RoomListViewState,
private val session: Session,
private val selectedGroupHolder: SelectedGroupStore,
private val visibleRoomHolder: VisibleRoomStore,
private val roomSelectionRepository: RoomSelectionRepository,
private val roomSummaryComparator: RoomSummaryComparator)
: RiotViewModel<RoomListViewState>(initialState) {
companion object : MvRxViewModelFactory<RoomListViewModel, RoomListViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: RoomListViewState): RoomListViewModel? {
val currentSession = viewModelContext.activity.get<Session>()
val roomSelectionRepository = viewModelContext.activity.get<RoomSelectionRepository>()
val selectedGroupHolder = viewModelContext.activity.get<SelectedGroupStore>()
val visibleRoomHolder = viewModelContext.activity.get<VisibleRoomStore>()
val roomSummaryComparator = viewModelContext.activity.get<RoomSummaryComparator>()
return RoomListViewModel(state, currentSession, selectedGroupHolder, visibleRoomHolder, roomSelectionRepository, roomSummaryComparator)
}
}
private val roomListFilter = BehaviorRelay.createDefault<Option<RoomListFilterName>>(Option.empty())
private val _openRoomLiveData = MutableLiveData<LiveEvent<String>>()
val openRoomLiveData: LiveData<LiveEvent<String>>
get() = _openRoomLiveData
init {
observeRoomSummaries()
observeVisibleRoom()
}
fun accept(action: RoomListActions) {
when (action) {
is RoomListActions.SelectRoom -> handleSelectRoom(action)
is RoomListActions.FilterRooms -> handleFilterRooms(action)
is RoomListActions.ToggleCategory -> handleToggleCategory(action)
}
}
// PRIVATE METHODS *****************************************************************************
private fun handleSelectRoom(action: RoomListActions.SelectRoom) = withState { state ->
if (state.visibleRoomId != action.roomSummary.roomId) {
roomSelectionRepository.saveLastSelectedRoom(action.roomSummary.roomId)
_openRoomLiveData.postValue(LiveEvent(action.roomSummary.roomId))
}
}
private fun handleFilterRooms(action: RoomListActions.FilterRooms) {
val optionalFilter = Option.fromNullable(action.roomName)
roomListFilter.accept(optionalFilter)
}
private fun handleToggleCategory(action: RoomListActions.ToggleCategory) = setState {
this.toggle(action.category)
}
private fun observeVisibleRoom() {
visibleRoomHolder.observe()
.doOnNext {
setState { copy(visibleRoomId = it) }
}
.subscribe()
.disposeOnClear()
}
private fun observeRoomSummaries() {
Observable.combineLatest<List<RoomSummary>, Option<GroupSummary>, Option<RoomListFilterName>, RoomSummaries>(
session.rx().liveRoomSummaries().throttleLast(300, TimeUnit.MILLISECONDS),
selectedGroupHolder.observe(),
roomListFilter.throttleLast(300, TimeUnit.MILLISECONDS),
Function3 { rooms, selectedGroupOption, filterRoomOption ->
val filteredRooms = filterRooms(rooms, filterRoomOption)
val selectedGroup = selectedGroupOption.orNull()
val filteredDirectRooms = filteredRooms
.filter { it.isDirect }
.filter {
if (selectedGroup == null) {
true
} else {
it.otherMemberIds
.intersect(selectedGroup.userIds)
.isNotEmpty()
}
}
val filteredGroupRooms = filteredRooms
.filter { !it.isDirect }
.filter {
selectedGroup?.roomIds?.contains(it.roomId) ?: true
}
buildRoomSummaries(filteredDirectRooms + filteredGroupRooms)
}
)
.execute { async ->
copy(
asyncRooms = async
)
}
}
private fun filterRooms(rooms: List<RoomSummary>, filterRoomOption: Option<RoomListFilterName>): List<RoomSummary> {
val filterRoom = filterRoomOption.orNull()
return rooms.filter {
if (filterRoom.isNullOrBlank()) {
true
} else {
it.displayName.contains(other = filterRoom, ignoreCase = true)
}
}
}
private fun buildRoomSummaries(rooms: List<RoomSummary>): RoomSummaries {
val favourites = ArrayList<RoomSummary>()
val directChats = ArrayList<RoomSummary>()
val groupRooms = ArrayList<RoomSummary>()
val lowPriorities = ArrayList<RoomSummary>()
val serverNotices = ArrayList<RoomSummary>()
for (room in rooms) {
val tags = room.tags.map { it.name }
when {
tags.contains(RoomTag.ROOM_TAG_SERVER_NOTICE) -> serverNotices.add(room)
tags.contains(RoomTag.ROOM_TAG_FAVOURITE) -> favourites.add(room)
tags.contains(RoomTag.ROOM_TAG_LOW_PRIORITY) -> lowPriorities.add(room)
room.isDirect -> directChats.add(room)
else -> groupRooms.add(room)
}
}
return RoomSummaries().apply {
put(RoomCategory.FAVOURITE, favourites.sortedWith(roomSummaryComparator))
put(RoomCategory.DIRECT, directChats.sortedWith(roomSummaryComparator))
put(RoomCategory.GROUP, groupRooms.sortedWith(roomSummaryComparator))
put(RoomCategory.LOW_PRIORITY, lowPriorities.sortedWith(roomSummaryComparator))
put(RoomCategory.SERVER_NOTICE, serverNotices.sortedWith(roomSummaryComparator))
}
}
}

View File

@ -0,0 +1,69 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import androidx.annotation.StringRes
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.R
data class RoomListViewState(
val asyncRooms: Async<RoomSummaries> = Uninitialized,
val visibleRoomId: String? = null,
val isFavouriteRoomsExpanded: Boolean = true,
val isDirectRoomsExpanded: Boolean = false,
val isGroupRoomsExpanded: Boolean = false,
val isLowPriorityRoomsExpanded: Boolean = false,
val isServerNoticeRoomsExpanded: Boolean = false
) : MvRxState {
fun isCategoryExpanded(roomCategory: RoomCategory): Boolean {
return when (roomCategory) {
RoomCategory.FAVOURITE -> isFavouriteRoomsExpanded
RoomCategory.DIRECT -> isDirectRoomsExpanded
RoomCategory.GROUP -> isGroupRoomsExpanded
RoomCategory.LOW_PRIORITY -> isLowPriorityRoomsExpanded
RoomCategory.SERVER_NOTICE -> isServerNoticeRoomsExpanded
}
}
fun toggle(roomCategory: RoomCategory): RoomListViewState {
return when (roomCategory) {
RoomCategory.FAVOURITE -> copy(isFavouriteRoomsExpanded = !isFavouriteRoomsExpanded)
RoomCategory.DIRECT -> copy(isDirectRoomsExpanded = !isDirectRoomsExpanded)
RoomCategory.GROUP -> copy(isGroupRoomsExpanded = !isGroupRoomsExpanded)
RoomCategory.LOW_PRIORITY -> copy(isLowPriorityRoomsExpanded = !isLowPriorityRoomsExpanded)
RoomCategory.SERVER_NOTICE -> copy(isServerNoticeRoomsExpanded = !isServerNoticeRoomsExpanded)
}
}
}
typealias RoomSummaries = LinkedHashMap<RoomCategory, List<RoomSummary>>
enum class RoomCategory(@StringRes val titleRes: Int) {
FAVOURITE(R.string.bottom_action_favourites),
DIRECT(R.string.bottom_action_people),
GROUP(R.string.bottom_action_rooms),
LOW_PRIORITY(R.string.low_priority_header),
SERVER_NOTICE(R.string.system_alerts_header)
}
fun RoomSummaries?.isNullOrEmpty(): Boolean {
return this == null || isEmpty()
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import android.content.SharedPreferences
private const val SHARED_PREFS_SELECTED_ROOM_KEY = "SHARED_PREFS_SELECTED_ROOM_KEY"
class RoomSelectionRepository(private val sharedPreferences: SharedPreferences) {
fun lastSelectedRoom(): String? {
return sharedPreferences.getString(SHARED_PREFS_SELECTED_ROOM_KEY, null)
}
fun saveLastSelectedRoom(roomId: String) {
sharedPreferences.edit()
.putString(SHARED_PREFS_SELECTED_ROOM_KEY, roomId)
.apply()
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import im.vector.matrix.android.api.session.room.model.RoomSummary
class RoomSummaryComparator
: Comparator<RoomSummary> {
override fun compare(leftRoomSummary: RoomSummary?, rightRoomSummary: RoomSummary?): Int {
val retValue: Int
var leftHighlightCount = 0
var rightHighlightCount = 0
var leftNotificationCount = 0
var rightNotificationCount = 0
var rightTimestamp = 0L
var leftTimestamp = 0L
if (null != leftRoomSummary) {
leftHighlightCount = leftRoomSummary.highlightCount
leftNotificationCount = leftRoomSummary.notificationCount
leftTimestamp = leftRoomSummary.lastMessage?.originServerTs ?: 0
}
if (null != rightRoomSummary) {
rightHighlightCount = rightRoomSummary.highlightCount
rightNotificationCount = rightRoomSummary.notificationCount
rightTimestamp = rightRoomSummary.lastMessage?.originServerTs ?: 0
}
if (rightRoomSummary?.lastMessage == null) {
retValue = -1
} else if (leftRoomSummary?.lastMessage == null) {
retValue = 1
} else if (rightHighlightCount > 0 && leftHighlightCount == 0) {
retValue = 1
} else if (rightHighlightCount == 0 && leftHighlightCount > 0) {
retValue = -1
} else if (rightNotificationCount > 0 && leftNotificationCount == 0) {
retValue = 1
} else if (rightNotificationCount == 0 && leftNotificationCount > 0) {
retValue = -1
} else {
val deltaTimestamp = rightTimestamp - leftTimestamp
if (deltaTimestamp > 0) {
retValue = 1
} else if (deltaTimestamp < 0) {
retValue = -1
} else {
retValue = 0
}
}
return retValue
}
}

View File

@ -0,0 +1,97 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import androidx.annotation.StringRes
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotredesign.core.resources.StringProvider
class RoomSummaryController(private val stringProvider: StringProvider
) : TypedEpoxyController<RoomListViewState>() {
var callback: Callback? = null
override fun buildModels(viewState: RoomListViewState) {
val roomSummaries = viewState.asyncRooms()
roomSummaries?.forEach { (category, summaries) ->
if (summaries.isEmpty()) {
return@forEach
} else {
val isExpanded = viewState.isCategoryExpanded(category)
buildRoomCategory(viewState, summaries, category.titleRes, viewState.isCategoryExpanded(category)) {
callback?.onToggleRoomCategory(category)
}
if (isExpanded) {
buildRoomModels(summaries, viewState.visibleRoomId)
}
}
}
}
private fun buildRoomCategory(viewState: RoomListViewState,
summaries: List<RoomSummary>,
@StringRes titleRes: Int,
isExpanded: Boolean,
mutateExpandedState: () -> Unit) {
if (summaries.isEmpty()) {
return
}
//TODO should add some business logic later
val unreadCount = if (summaries.isEmpty()) {
0
} else {
summaries.map { it.notificationCount }.reduce { acc, i -> acc + i }
}
val showHighlighted = summaries.any { it.highlightCount > 0 }
roomCategoryItem {
id(titleRes)
title(stringProvider.getString(titleRes).toUpperCase())
expanded(isExpanded)
unreadCount(unreadCount)
showHighlighted(showHighlighted)
listener {
mutateExpandedState()
setData(viewState)
}
}
}
private fun buildRoomModels(summaries: List<RoomSummary>, selectedRoomId: String?) {
summaries.forEach { roomSummary ->
val unreadCount = roomSummary.notificationCount
val showHighlighted = roomSummary.highlightCount > 0
val isSelected = roomSummary.roomId == selectedRoomId
roomSummaryItem {
id(roomSummary.roomId)
roomName(roomSummary.displayName)
avatarUrl(roomSummary.avatarUrl)
selected(isSelected)
showHighlighted(showHighlighted)
unreadCount(unreadCount)
listener { callback?.onRoomSelected(roomSummary) }
}
}
}
interface Callback {
fun onToggleRoomCategory(roomCategory: RoomCategory)
fun onRoomSelected(room: RoomSummary)
}
}

View File

@ -0,0 +1,35 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
object RoomSummaryFormatter {
/**
* Format the unread messages counter.
*
* @param count the count
* @return the formatted value
*/
fun formatUnreadMessagesCounter(count: Int): String {
return if (count > 999) {
"${count / 1000}.${count % 1000 / 100}K"
} else {
count.toString()
}
}
}

View File

@ -0,0 +1,57 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.RiotEpoxyHolder
import im.vector.riotredesign.core.epoxy.RiotEpoxyModel
import im.vector.riotredesign.core.platform.CheckableFrameLayout
import im.vector.riotredesign.features.home.AvatarRenderer
@EpoxyModelClass(layout = R.layout.item_room)
abstract class RoomSummaryItem : RiotEpoxyModel<RoomSummaryItem.Holder>() {
@EpoxyAttribute lateinit var roomName: CharSequence
@EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var selected: Boolean = false
@EpoxyAttribute var unreadCount: Int = 0
@EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var listener: (() -> Unit)? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.unreadCounterBadgeView.render(unreadCount, showHighlighted)
holder.rootView.isChecked = selected
holder.rootView.setOnClickListener { listener?.invoke() }
holder.titleView.text = roomName
AvatarRenderer.render(avatarUrl, roomName.toString(), holder.avatarImageView)
}
class Holder : RiotEpoxyHolder() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomUnreadCounterBadgeView)
val titleView by bind<TextView>(R.id.roomNameView)
val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
val rootView by bind<CheckableFrameLayout>(R.id.itemRoomLayout)
}
}

View File

@ -0,0 +1,52 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.list
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import im.vector.riotredesign.R
class UnreadCounterBadgeView : AppCompatTextView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
fun render(count: Int, highlighted: Boolean) {
if (count == 0) {
visibility = View.INVISIBLE
} else {
visibility = View.VISIBLE
val bgRes = if (highlighted) {
R.drawable.bg_unread_highlight
} else {
R.drawable.bg_unread_notification
}
setBackgroundResource(bgRes)
text = RoomSummaryFormatter.formatUnreadMessagesCounter(count)
}
}
enum class Status {
NOTIFICATION,
HIGHLIGHT
}
}

View File

@ -0,0 +1,186 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package im.vector.riotredesign.features.html
import android.content.Context
import im.vector.matrix.android.api.permalinks.PermalinkData
import im.vector.matrix.android.api.permalinks.PermalinkParser
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.core.glide.GlideRequests
import org.commonmark.node.BlockQuote
import org.commonmark.node.HtmlBlock
import org.commonmark.node.HtmlInline
import org.commonmark.node.Node
import ru.noties.markwon.AbstractMarkwonPlugin
import ru.noties.markwon.Markwon
import ru.noties.markwon.MarkwonConfiguration
import ru.noties.markwon.MarkwonVisitor
import ru.noties.markwon.SpannableBuilder
import ru.noties.markwon.html.HtmlTag
import ru.noties.markwon.html.MarkwonHtmlParserImpl
import ru.noties.markwon.html.MarkwonHtmlRenderer
import ru.noties.markwon.html.TagHandler
import ru.noties.markwon.html.tag.BlockquoteHandler
import ru.noties.markwon.html.tag.EmphasisHandler
import ru.noties.markwon.html.tag.HeadingHandler
import ru.noties.markwon.html.tag.ImageHandler
import ru.noties.markwon.html.tag.LinkHandler
import ru.noties.markwon.html.tag.ListHandler
import ru.noties.markwon.html.tag.StrikeHandler
import ru.noties.markwon.html.tag.StrongEmphasisHandler
import ru.noties.markwon.html.tag.SubScriptHandler
import ru.noties.markwon.html.tag.SuperScriptHandler
import ru.noties.markwon.html.tag.UnderlineHandler
import java.util.Arrays.asList
class EventHtmlRenderer(glideRequests: GlideRequests,
context: Context,
session: Session) {
private val markwon = Markwon.builder(context)
.usePlugin(MatrixPlugin.create(glideRequests, context, session))
.build()
fun render(text: String): CharSequence {
return markwon.toMarkdown(text)
}
}
private class MatrixPlugin private constructor(private val glideRequests: GlideRequests,
private val context: Context,
private val session: Session) : AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder.htmlParser(MarkwonHtmlParserImpl.create())
}
override fun configureHtmlRenderer(builder: MarkwonHtmlRenderer.Builder) {
builder
.setHandler(
"img",
ImageHandler.create())
.setHandler(
"a",
MxLinkHandler(glideRequests, context, session))
.setHandler(
"blockquote",
BlockquoteHandler())
.setHandler(
"sub",
SubScriptHandler())
.setHandler(
"sup",
SuperScriptHandler())
.setHandler(
asList<String>("b", "strong"),
StrongEmphasisHandler())
.setHandler(
asList<String>("s", "del"),
StrikeHandler())
.setHandler(
asList<String>("u", "ins"),
UnderlineHandler())
.setHandler(
asList<String>("ul", "ol"),
ListHandler())
.setHandler(
asList<String>("i", "em", "cite", "dfn"),
EmphasisHandler())
.setHandler(
asList<String>("h1", "h2", "h3", "h4", "h5", "h6"),
HeadingHandler())
.setHandler("mx-reply",
MxReplyTagHandler())
}
override fun afterRender(node: Node, visitor: MarkwonVisitor) {
val configuration = visitor.configuration()
configuration.htmlRenderer().render(visitor, configuration.htmlParser())
}
override fun configureVisitor(builder: MarkwonVisitor.Builder) {
builder
.on(HtmlBlock::class.java) { visitor, htmlBlock -> visitHtml(visitor, htmlBlock.literal) }
.on(HtmlInline::class.java) { visitor, htmlInline -> visitHtml(visitor, htmlInline.literal) }
}
private fun visitHtml(visitor: MarkwonVisitor, html: String?) {
if (html != null) {
visitor.configuration().htmlParser().processFragment(visitor.builder(), html)
}
}
companion object {
fun create(glideRequests: GlideRequests, context: Context, session: Session): MatrixPlugin {
return MatrixPlugin(glideRequests, context, session)
}
}
}
private class MxLinkHandler(private val glideRequests: GlideRequests,
private val context: Context,
private val session: Session) : TagHandler() {
private val linkHandler = LinkHandler()
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
val link = tag.attributes()["href"]
if (link != null) {
val permalinkData = PermalinkParser.parse(link)
when (permalinkData) {
is PermalinkData.UserLink -> {
val user = session.getUser(permalinkData.userId)
val span = PillImageSpan(glideRequests, context, permalinkData.userId, user)
SpannableBuilder.setSpans(
visitor.builder(),
span,
tag.start(),
tag.end()
)
}
else -> linkHandler.handle(visitor, renderer, tag)
}
} else {
linkHandler.handle(visitor, renderer, tag)
}
}
}
private class MxReplyTagHandler : TagHandler() {
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
val configuration = visitor.configuration()
val factory = configuration.spansFactory().get(BlockQuote::class.java)
if (factory != null) {
SpannableBuilder.setSpans(
visitor.builder(),
factory.getSpans(configuration, visitor.renderProps()),
tag.start(),
tag.end()
)
val replyText = visitor.builder().removeFromEnd(tag.end())
visitor.builder().append("\n\n").append(replyText)
}
}
}

View File

@ -0,0 +1,136 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.html
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.text.style.ReplacementSpan
import android.widget.TextView
import androidx.annotation.UiThread
import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.chip.ChipDrawable
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R
import im.vector.riotredesign.core.glide.GlideRequests
import im.vector.riotredesign.features.home.AvatarRenderer
import java.lang.ref.WeakReference
/**
* This span is able to replace a text by a [ChipDrawable]
* It's needed to call [bind] method to start requesting avatar, otherwise only the placeholder icon will be displayed if not already cached.
*/
class PillImageSpan(private val glideRequests: GlideRequests,
private val context: Context,
private val userId: String,
private val user: User?) : ReplacementSpan() {
private val displayName by lazy {
if (user?.displayName.isNullOrEmpty()) userId else user?.displayName!!
}
private val pillDrawable = createChipDrawable()
private val target = PillImageSpanTarget(this)
private var tv: WeakReference<TextView>? = null
@UiThread
fun bind(textView: TextView) {
tv = WeakReference(textView)
AvatarRenderer.render(context, glideRequests, user?.avatarUrl, displayName, target)
}
// ReplacementSpan *****************************************************************************
override fun getSize(paint: Paint, text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?): Int {
val rect = pillDrawable.bounds
if (fm != null) {
fm.ascent = -rect.bottom
fm.descent = 0
fm.top = fm.ascent
fm.bottom = 0
}
return rect.right
}
override fun draw(canvas: Canvas, text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint) {
canvas.save()
val transY = bottom - pillDrawable.bounds.bottom
canvas.translate(x, transY.toFloat())
pillDrawable.draw(canvas)
canvas.restore()
}
internal fun updateAvatarDrawable(drawable: Drawable?) {
pillDrawable.apply {
chipIcon = drawable
}
tv?.get()?.apply {
invalidate()
}
}
// Private methods *****************************************************************************
private fun createChipDrawable(): ChipDrawable {
val textPadding = context.resources.getDimension(R.dimen.pill_text_padding)
return ChipDrawable.createFromResource(context, R.xml.pill_view).apply {
setText(displayName)
textEndPadding = textPadding
textStartPadding = textPadding
setChipMinHeightResource(R.dimen.pill_min_height)
setChipIconSizeResource(R.dimen.pill_avatar_size)
chipIcon = AvatarRenderer.getPlaceholderDrawable(context, displayName)
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
}
}
}
/**
* Glide target to handle avatar retrieval into [PillImageSpan].
*/
private class PillImageSpanTarget(pillImageSpan: PillImageSpan) : SimpleTarget<Drawable>() {
private val pillImageSpan = WeakReference(pillImageSpan)
override fun onResourceReady(drawable: Drawable, transition: Transition<in Drawable>?) {
updateWith(drawable)
}
override fun onLoadCleared(placeholder: Drawable?) {
updateWith(placeholder)
}
private fun updateWith(drawable: Drawable?) {
pillImageSpan.get()?.apply {
updateAvatarDrawable(drawable)
}
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.login
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import arrow.core.Try
import com.jakewharton.rxbinding2.widget.RxTextView
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.RiotActivity
import im.vector.riotredesign.features.home.HomeActivity
import io.reactivex.Observable
import io.reactivex.functions.Function3
import io.reactivex.rxkotlin.subscribeBy
import kotlinx.android.synthetic.main.activity_login.*
private const val DEFAULT_HOME_SERVER_URI = "https://matrix.org"
private const val DEFAULT_IDENTITY_SERVER_URI = "https://vector.im"
private const val DEFAULT_ANTIVIRUS_SERVER_URI = "https://matrix.org"
class LoginActivity : RiotActivity() {
private val authenticator = Matrix.getInstance().authenticator()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
setupAuthButton()
homeServerField.setText(DEFAULT_HOME_SERVER_URI)
}
private fun authenticate() {
val login = loginField.text?.trim().toString()
val password = passwordField.text?.trim().toString()
buildHomeServerConnectionConfig().fold(
{ Toast.makeText(this@LoginActivity, "Authenticate failure: $it", Toast.LENGTH_LONG).show() },
{ authenticateWith(it, login, password) }
)
}
private fun authenticateWith(homeServerConnectionConfig: HomeServerConnectionConfig, login: String, password: String) {
progressBar.visibility = View.VISIBLE
authenticator.authenticate(homeServerConnectionConfig, login, password, object : MatrixCallback<Session> {
override fun onSuccess(data: Session) {
Matrix.getInstance().currentSession = data.apply { open() }
goToHome()
}
override fun onFailure(failure: Throwable) {
progressBar.visibility = View.GONE
Toast.makeText(this@LoginActivity, "Authenticate failure: $failure", Toast.LENGTH_LONG).show()
}
})
}
private fun buildHomeServerConnectionConfig(): Try<HomeServerConnectionConfig> {
return Try {
val homeServerUri = homeServerField.text?.trim().toString()
HomeServerConnectionConfig.Builder()
.withHomeServerUri(homeServerUri)
.withIdentityServerUri(DEFAULT_IDENTITY_SERVER_URI)
.withAntiVirusServerUri(DEFAULT_ANTIVIRUS_SERVER_URI)
.build()
}
}
private fun setupAuthButton() {
Observable
.combineLatest(
RxTextView.textChanges(loginField).map { it.trim().isNotEmpty() },
RxTextView.textChanges(passwordField).map { it.trim().isNotEmpty() },
RxTextView.textChanges(homeServerField).map { it.trim().isNotEmpty() },
Function3<Boolean, Boolean, Boolean, Boolean> { isLoginNotEmpty, isPasswordNotEmpty, isHomeServerNotEmpty ->
isLoginNotEmpty && isPasswordNotEmpty && isHomeServerNotEmpty
}
)
.subscribeBy { authenticateButton.isEnabled = it }
.disposeOnDestroy()
authenticateButton.setOnClickListener { authenticate() }
}
private fun goToHome() {
val intent = HomeActivity.newIntent(this)
startActivity(intent)
finish()
}
companion object {
fun newIntent(context: Context): Intent {
return Intent(context, LoginActivity::class.java)
}
}
}

View File

@ -0,0 +1,53 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package im.vector.riotredesign.features.media
import com.github.piasy.biv.loader.ImageLoader
import java.io.File
interface DefaultImageLoaderCallback : ImageLoader.Callback {
override fun onFinish() {
//no-op
}
override fun onSuccess(image: File?) {
//no-op
}
override fun onFail(error: Exception?) {
//no-op
}
override fun onCacheHit(imageType: Int, image: File?) {
//no-op
}
override fun onCacheMiss(imageType: Int, image: File?) {
//no-op
}
override fun onProgress(progress: Int) {
//no-op
}
override fun onStart() {
//no-op
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.media
import android.media.ExifInterface
import android.net.Uri
import android.os.Parcelable
import android.widget.ImageView
import com.github.piasy.biv.view.BigImageView
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.riotredesign.core.glide.GlideApp
import kotlinx.android.parcel.Parcelize
object MediaContentRenderer {
@Parcelize
data class Data(
val filename: String,
val url: String?,
val height: Int?,
val maxHeight: Int,
val width: Int?,
val maxWidth: Int,
val orientation: Int?,
val rotation: Int?
) : Parcelable
enum class Mode {
FULL_SIZE,
THUMBNAIL
}
fun render(data: Data, mode: Mode, imageView: ImageView) {
val (width, height) = processSize(data, mode)
imageView.layoutParams.height = height
imageView.layoutParams.width = width
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val resolvedUrl = when (mode) {
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
} ?: return
GlideApp
.with(imageView)
.load(resolvedUrl)
.thumbnail(0.3f)
.into(imageView)
}
fun render(data: Data, imageView: BigImageView) {
val (width, height) = processSize(data, Mode.THUMBNAIL)
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val fullSize = contentUrlResolver.resolveFullSize(data.url)
val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
imageView.showImage(
Uri.parse(thumbnail),
Uri.parse(fullSize)
)
}
private fun processSize(data: Data, mode: Mode): Pair<Int, Int> {
val maxImageWidth = data.maxWidth
val maxImageHeight = data.maxHeight
val rotationAngle = data.rotation ?: 0
val orientation = data.orientation ?: ExifInterface.ORIENTATION_NORMAL
var width = data.width ?: maxImageWidth
var height = data.height ?: maxImageHeight
var finalHeight = -1
var finalWidth = -1
// if the image size is known
// compute the expected height
if (width > 0 && height > 0) {
// swap width and height if the image is side oriented
if (rotationAngle == 90 || rotationAngle == 270) {
val tmp = width
width = height
height = tmp
} else if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) {
val tmp = width
width = height
height = tmp
}
if (mode == Mode.FULL_SIZE) {
finalHeight = height
finalWidth = width
} else {
finalHeight = Math.min(maxImageWidth * height / width, maxImageHeight)
finalWidth = finalHeight * width / height
}
}
// ensure that some values are properly initialized
if (finalHeight < 0) {
finalHeight = maxImageHeight
}
if (finalWidth < 0) {
finalWidth = maxImageWidth
}
return Pair(finalWidth, finalHeight)
}
}

View File

@ -0,0 +1,68 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package im.vector.riotredesign.features.media
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.widget.Toolbar
import com.github.piasy.biv.indicator.progresspie.ProgressPieIndicator
import com.github.piasy.biv.view.GlideImageViewFactory
import im.vector.riotredesign.core.platform.RiotActivity
import kotlinx.android.synthetic.main.activity_media_viewer.*
class MediaViewerActivity : RiotActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(im.vector.riotredesign.R.layout.activity_media_viewer)
val mediaData = intent.getParcelableExtra<MediaContentRenderer.Data>(EXTRA_MEDIA_DATA)
if (mediaData.url.isNullOrEmpty()) {
finish()
} else {
configureToolbar(mediaViewerToolbar, mediaData)
mediaViewerImageView.setImageViewFactory(GlideImageViewFactory())
mediaViewerImageView.setProgressIndicator(ProgressPieIndicator())
MediaContentRenderer.render(mediaData, mediaViewerImageView)
}
}
private fun configureToolbar(toolbar: Toolbar, mediaData: MediaContentRenderer.Data) {
setSupportActionBar(toolbar)
supportActionBar?.apply {
title = mediaData.filename
setHomeButtonEnabled(true)
setDisplayHomeAsUpEnabled(true)
}
}
companion object {
private const val EXTRA_MEDIA_DATA = "EXTRA_MEDIA_DATA"
fun newIntent(context: Context, mediaData: MediaContentRenderer.Data): Intent {
return Intent(context, MediaViewerActivity::class.java).apply {
putExtra(EXTRA_MEDIA_DATA, mediaData)
}
}
}
}

View File

@ -0,0 +1,209 @@
/*
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.rageshake
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.*
import androidx.core.view.isVisible
import butterknife.BindView
import butterknife.OnCheckedChanged
import butterknife.OnTextChanged
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.RiotActivity
import timber.log.Timber
/**
* Form to send a bug report
*/
class BugReportActivity : RiotActivity() {
/* ==========================================================================================
* UI
* ========================================================================================== */
@BindView(R.id.bug_report_edit_text)
lateinit var mBugReportText: EditText
@BindView(R.id.bug_report_button_include_logs)
lateinit var mIncludeLogsButton: CheckBox
@BindView(R.id.bug_report_button_include_crash_logs)
lateinit var mIncludeCrashLogsButton: CheckBox
@BindView(R.id.bug_report_button_include_screenshot)
lateinit var mIncludeScreenShotButton: CheckBox
@BindView(R.id.bug_report_screenshot_preview)
lateinit var mScreenShotPreview: ImageView
@BindView(R.id.bug_report_progress_view)
lateinit var mProgressBar: ProgressBar
@BindView(R.id.bug_report_progress_text_view)
lateinit var mProgressTextView: TextView
@BindView(R.id.bug_report_scrollview)
lateinit var mScrollView: View
@BindView(R.id.bug_report_mask_view)
lateinit var mMaskView: View
override fun getLayoutRes() = R.layout.activity_bug_report
override fun initUiAndData() {
configureToolbar()
if (BugReporter.screenshot != null) {
mScreenShotPreview.setImageBitmap(BugReporter.screenshot)
} else {
mScreenShotPreview.isVisible = false
mIncludeScreenShotButton.isChecked = false
mIncludeScreenShotButton.isEnabled = false
}
}
override fun getMenuRes() = R.menu.bug_report
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.ic_action_send_bug_report)?.let {
val isValid = mBugReportText.text.toString().trim().length > 10
&& !mMaskView.isVisible
it.isEnabled = isValid
it.icon.alpha = if (isValid) 255 else 100
}
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.ic_action_send_bug_report -> {
sendBugReport()
return true
}
}
return super.onOptionsItemSelected(item)
}
/**
* Send the bug report
*/
private fun sendBugReport() {
mScrollView.alpha = 0.3f
mMaskView.isVisible = true
invalidateOptionsMenu()
mProgressTextView.isVisible = true
mProgressTextView.text = getString(R.string.send_bug_report_progress, 0.toString() + "")
mProgressBar.isVisible = true
mProgressBar.progress = 0
BugReporter.sendBugReport(this,
mIncludeLogsButton.isChecked,
mIncludeCrashLogsButton.isChecked,
mIncludeScreenShotButton.isChecked,
mBugReportText.text.toString(),
object : BugReporter.IMXBugReportListener {
override fun onUploadFailed(reason: String?) {
try {
if (!TextUtils.isEmpty(reason)) {
Toast.makeText(this@BugReportActivity,
getString(R.string.send_bug_report_failed, reason), Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
Timber.e(e, "## onUploadFailed() : failed to display the toast " + e.message)
}
mMaskView.isVisible = false
mProgressBar.isVisible = false
mProgressTextView.isVisible = false
mScrollView.alpha = 1.0f
invalidateOptionsMenu()
}
override fun onUploadCancelled() {
onUploadFailed(null)
}
override fun onProgress(progress: Int) {
var progress = progress
if (progress > 100) {
Timber.e("## onProgress() : progress > 100")
progress = 100
} else if (progress < 0) {
Timber.e("## onProgress() : progress < 0")
progress = 0
}
mProgressBar.progress = progress
mProgressTextView.text = getString(R.string.send_bug_report_progress, progress.toString() + "")
}
override fun onUploadSucceed() {
try {
Toast.makeText(this@BugReportActivity, R.string.send_bug_report_sent, Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Timber.e(e, "## onUploadSucceed() : failed to dismiss the toast " + e.message)
}
try {
finish()
} catch (e: Exception) {
Timber.e(e, "## onUploadSucceed() : failed to dismiss the dialog " + e.message)
}
}
})
}
/* ==========================================================================================
* UI Event
* ========================================================================================== */
@OnTextChanged(R.id.bug_report_edit_text)
internal fun textChanged() {
invalidateOptionsMenu()
}
@OnCheckedChanged(R.id.bug_report_button_include_screenshot)
internal fun onSendScreenshotChanged() {
mScreenShotPreview.isVisible = mIncludeScreenShotButton.isChecked && BugReporter.screenshot != null
}
override fun onBackPressed() {
// Ensure there is no crash status remaining, which will be sent later on by mistake
BugReporter.deleteCrashFile(this)
super.onBackPressed()
}
/* ==========================================================================================
* Companion
* ========================================================================================== */
companion object {
private val LOG_TAG = BugReportActivity::class.java.simpleName
}
}

View File

@ -0,0 +1,687 @@
/*
* Copyright 2016 OpenMarket Ltd
* Copyright 2017 Vector Creations Ltd
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.rageshake
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.AsyncTask
import android.os.Build
import android.text.TextUtils
import android.view.View
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import im.vector.riotredesign.core.extensions.toOnOff
import im.vector.riotredesign.core.utils.getDeviceLocale
import im.vector.riotredesign.features.settings.VectorLocale
import im.vector.riotredesign.features.themes.ThemeUtils
import okhttp3.*
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.*
import java.net.HttpURLConnection
import java.util.*
import java.util.zip.GZIPOutputStream
/**
* BugReporter creates and sends the bug reports.
*/
object BugReporter {
var inMultiWindowMode = false
// filenames
private const val LOG_CAT_ERROR_FILENAME = "logcatError.log"
private const val LOG_CAT_FILENAME = "logcat.log"
private const val LOG_CAT_SCREENSHOT_FILENAME = "screenshot.png"
private const val CRASH_FILENAME = "crash.log"
// the http client
private val mOkHttpClient = OkHttpClient()
// the pending bug report call
private var mBugReportCall: Call? = null
// boolean to cancel the bug report
private val mIsCancelled = false
/**
* Get current Screenshot
*
* @return screenshot or null if not available
*/
var screenshot: Bitmap? = null
private set
private const val BUFFER_SIZE = 1024 * 1024 * 50
private val LOGCAT_CMD_ERROR = arrayOf("logcat", ///< Run 'logcat' command
"-d", ///< Dump the log rather than continue outputting it
"-v", // formatting
"threadtime", // include timestamps
"AndroidRuntime:E " + ///< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging
"libcommunicator:V " + ///< All libcommunicator logging
"DEBUG:V " + ///< All DEBUG logging - which includes native land crashes (seg faults, etc)
"*:S" ///< Everything else silent, so don't pick it..
)
private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
/**
* Bug report upload listener
*/
interface IMXBugReportListener {
/**
* The bug report has been cancelled
*/
fun onUploadCancelled()
/**
* The bug report upload failed.
*
* @param reason the failure reason
*/
fun onUploadFailed(reason: String?)
/**
* The upload progress (in percent)
*
* @param progress the upload progress
*/
fun onProgress(progress: Int)
/**
* The bug report upload succeeded.
*/
fun onUploadSucceed()
}
/**
* Send a bug report.
*
* @param context the application context
* @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs
* @param withScreenshot true to include the screenshot
* @param theBugDescription the bug description
* @param listener the listener
*/
@SuppressLint("StaticFieldLeak")
fun sendBugReport(context: Context,
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withScreenshot: Boolean,
theBugDescription: String,
listener: IMXBugReportListener?) {
object : AsyncTask<Void, Int, String>() {
// enumerate files to delete
val mBugReportFiles: MutableList<File> = ArrayList()
override fun doInBackground(vararg voids: Void?): String? {
var bugDescription = theBugDescription
var serverError: String? = null
val crashCallStack = getCrashDescription(context)
if (null != crashCallStack) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
bugDescription += crashCallStack
}
val gzippedFiles = ArrayList<File>()
if (withDevicesLogs) {
val files = VectorFileLogger.getLogFiles()
for (f in files) {
if (!mIsCancelled) {
val gzippedFile = compressFile(f)
if (null != gzippedFile) {
gzippedFiles.add(gzippedFile)
}
}
}
}
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
val gzippedLogcat = saveLogCat(context, false)
if (null != gzippedLogcat) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(gzippedLogcat)
} else {
gzippedFiles.add(0, gzippedLogcat)
}
}
val crashDescription = getCrashFile(context)
if (crashDescription.exists()) {
val compressedCrashDescription = compressFile(crashDescription)
if (null != compressedCrashDescription) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(compressedCrashDescription)
} else {
gzippedFiles.add(0, compressedCrashDescription)
}
}
}
}
var deviceId = "undefined"
var userId = "undefined"
var matrixSdkVersion = "undefined"
var olmVersion = "undefined"
Matrix.getInstance().currentSession?.let { session ->
userId = session.sessionParams.credentials.userId
deviceId = session.sessionParams.credentials.deviceId ?: "undefined"
// TODO matrixSdkVersion = session.getVersion(true);
// TODO olmVersion = session.getCryptoVersion(context, true);
}
if (!mIsCancelled) {
// build the multi part request
val builder = BugReporterMultipartBody.Builder()
.addFormDataPart("text", "[RiotX] $bugDescription")
.addFormDataPart("app", "riot-android")
.addFormDataPart("user_agent", Matrix.getInstance().getUserAgent())
.addFormDataPart("user_id", userId)
.addFormDataPart("device_id", deviceId)
// TODO .addFormDataPart("version", Matrix.getInstance(context).getVersion(true, false))
.addFormDataPart("branch_name", context.getString(R.string.git_branch_name))
.addFormDataPart("matrix_sdk_version", matrixSdkVersion)
.addFormDataPart("olm_version", olmVersion)
.addFormDataPart("device", Build.MODEL.trim())
.addFormDataPart("lazy_loading", true.toOnOff())
.addFormDataPart("multi_window", inMultiWindowMode.toOnOff())
.addFormDataPart("os", Build.VERSION.RELEASE + " (API " + Build.VERSION.SDK_INT + ") "
+ Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME)
.addFormDataPart("locale", Locale.getDefault().toString())
.addFormDataPart("app_language", VectorLocale.applicationLocale.toString())
.addFormDataPart("default_app_language", getDeviceLocale(context).toString())
.addFormDataPart("theme", ThemeUtils.getApplicationTheme(context))
val buildNumber = context.getString(R.string.build_number)
if (!TextUtils.isEmpty(buildNumber) && buildNumber != "0") {
builder.addFormDataPart("build_number", buildNumber)
}
// add the gzipped files
for (file in gzippedFiles) {
builder.addFormDataPart("compressed-log", file.name, RequestBody.create(MediaType.parse("application/octet-stream"), file))
}
mBugReportFiles.addAll(gzippedFiles)
if (withScreenshot) {
val bitmap = screenshot
if (null != bitmap) {
val logCatScreenshotFile = File(context.cacheDir.absolutePath, LOG_CAT_SCREENSHOT_FILENAME)
if (logCatScreenshotFile.exists()) {
logCatScreenshotFile.delete()
}
try {
val fos = FileOutputStream(logCatScreenshotFile)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
fos.flush()
fos.close()
builder.addFormDataPart("file",
logCatScreenshotFile.name, RequestBody.create(MediaType.parse("application/octet-stream"), logCatScreenshotFile))
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to write screenshot$e")
}
}
}
screenshot = null
// add some github labels
builder.addFormDataPart("label", BuildConfig.VERSION_NAME)
builder.addFormDataPart("label", BuildConfig.FLAVOR_DESCRIPTION)
builder.addFormDataPart("label", context.getString(R.string.git_branch_name))
// Special for RiotX
builder.addFormDataPart("label", "[RiotX]")
if (getCrashFile(context).exists()) {
builder.addFormDataPart("label", "crash")
deleteCrashFile(context)
}
val requestBody = builder.build()
// add a progress listener
requestBody.setWriteListener { totalWritten, contentLength ->
val percentage: Int
if (-1L != contentLength) {
if (totalWritten > contentLength) {
percentage = 100
} else {
percentage = (totalWritten * 100 / contentLength).toInt()
}
} else {
percentage = 0
}
if (mIsCancelled && null != mBugReportCall) {
mBugReportCall!!.cancel()
}
Timber.d("## onWrite() : $percentage%")
publishProgress(percentage)
}
// build the request
val request = Request.Builder()
.url(context.getString(R.string.bug_report_url))
.post(requestBody)
.build()
var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR
var response: Response? = null
var errorMessage: String? = null
// trigger the request
try {
mBugReportCall = mOkHttpClient.newCall(request)
response = mBugReportCall!!.execute()
responseCode = response!!.code()
} catch (e: Exception) {
Timber.e(e, "response " + e.message)
errorMessage = e.localizedMessage
}
// if the upload failed, try to retrieve the reason
if (responseCode != HttpURLConnection.HTTP_OK) {
if (null != errorMessage) {
serverError = "Failed with error $errorMessage"
} else if (null == response || null == response.body()) {
serverError = "Failed with error $responseCode"
} else {
var `is`: InputStream? = null
try {
`is` = response.body()!!.byteStream()
if (null != `is`) {
var ch = `is`.read()
val b = StringBuilder()
while (ch != -1) {
b.append(ch.toChar())
ch = `is`.read()
}
serverError = b.toString()
`is`.close()
// check if the error message
try {
val responseJSON = JSONObject(serverError)
serverError = responseJSON.getString("error")
} catch (e: JSONException) {
Timber.e(e, "doInBackground ; Json conversion failed " + e.message)
}
// should never happen
if (null == serverError) {
serverError = "Failed with error $responseCode"
}
}
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to parse error " + e.message)
} finally {
try {
`is`?.close()
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to close the error stream " + e.message)
}
}
}
}
}
return serverError
}
override fun onProgressUpdate(vararg progress: Int?) {
if (null != listener) {
try {
listener.onProgress(progress?.get(0) ?: 0)
} catch (e: Exception) {
Timber.e(e, "## onProgress() : failed " + e.message)
}
}
}
override fun onPostExecute(reason: String?) {
mBugReportCall = null
// delete when the bug report has been successfully sent
for (file in mBugReportFiles) {
file.delete()
}
if (null != listener) {
try {
if (mIsCancelled) {
listener.onUploadCancelled()
} else if (null == reason) {
listener.onUploadSucceed()
} else {
listener.onUploadFailed(reason)
}
} catch (e: Exception) {
Timber.e(e, "## onPostExecute() : failed " + e.message)
}
}
}
}.execute()
}
/**
* Send a bug report either with email or with Vector.
*/
fun openBugReportScreen(activity: Activity) {
screenshot = takeScreenshot(activity)
val intent = Intent(activity, BugReportActivity::class.java)
activity.startActivity(intent)
}
//==============================================================================================================
// crash report management
//==============================================================================================================
/**
* Provides the crash file
*
* @param context the context
* @return the crash file
*/
private fun getCrashFile(context: Context): File {
return File(context.cacheDir.absolutePath, CRASH_FILENAME)
}
/**
* Remove the crash file
*
* @param context
*/
fun deleteCrashFile(context: Context) {
val crashFile = getCrashFile(context)
if (crashFile.exists()) {
crashFile.delete()
}
// Also reset the screenshot
screenshot = null
}
/**
* Save the crash report
*
* @param context the context
* @param crashDescription teh crash description
*/
fun saveCrashReport(context: Context, crashDescription: String) {
val crashFile = getCrashFile(context)
if (crashFile.exists()) {
crashFile.delete()
}
if (!TextUtils.isEmpty(crashDescription)) {
try {
val fos = FileOutputStream(crashFile)
val osw = OutputStreamWriter(fos)
osw.write(crashDescription)
osw.close()
fos.flush()
fos.close()
} catch (e: Exception) {
Timber.e(e, "## saveCrashReport() : fail to write $e")
}
}
}
/**
* Read the crash description file and return its content.
*
* @param context teh context
* @return the crash description
*/
private fun getCrashDescription(context: Context): String? {
var crashDescription: String? = null
val crashFile = getCrashFile(context)
if (crashFile.exists()) {
try {
val fis = FileInputStream(crashFile)
val isr = InputStreamReader(fis)
val buffer = CharArray(fis.available())
val len = isr.read(buffer, 0, fis.available())
crashDescription = String(buffer, 0, len)
isr.close()
fis.close()
} catch (e: Exception) {
Timber.e(e, "## getCrashDescription() : fail to read $e")
}
}
return crashDescription
}
//==============================================================================================================
// Screenshot management
//==============================================================================================================
/**
* Take a screenshot of the display.
*
* @return the screenshot
*/
private fun takeScreenshot(activity: Activity): Bitmap? {
// get content view
val contentView = activity.findViewById<View>(android.R.id.content)
if (contentView == null) {
Timber.e("Cannot find content view on $activity. Cannot take screenshot.")
return null
}
// get the root view to snapshot
val rootView = contentView.rootView
if (rootView == null) {
Timber.e("Cannot find root view on $activity. Cannot take screenshot.")
return null
}
// refresh it
rootView.isDrawingCacheEnabled = false
rootView.isDrawingCacheEnabled = true
try {
var bitmap = rootView.drawingCache
// Make a copy, because if Activity is destroyed, the bitmap will be recycled
bitmap = Bitmap.createBitmap(bitmap)
return bitmap
} catch (oom: OutOfMemoryError) {
Timber.e(oom, "Cannot get drawing cache for $activity OOM.")
} catch (e: Exception) {
Timber.e(e, "Cannot get snapshot of screen: $e")
}
return null
}
//==============================================================================================================
// Logcat management
//==============================================================================================================
/**
* Save the logcat
*
* @param context the context
* @param isErrorLogcat true to save the error logcat
* @return the file if the operation succeeds
*/
private fun saveLogCat(context: Context, isErrorLogcat: Boolean): File? {
val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME)
if (logCatErrFile.exists()) {
logCatErrFile.delete()
}
try {
val fos = FileOutputStream(logCatErrFile)
val osw = OutputStreamWriter(fos)
getLogCatError(osw, isErrorLogcat)
osw.close()
fos.flush()
fos.close()
return compressFile(logCatErrFile)
} catch (error: OutOfMemoryError) {
Timber.e(error, "## saveLogCat() : fail to write logcat$error")
} catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat$e")
}
return null
}
/**
* Retrieves the logs
*
* @param streamWriter the stream writer
* @param isErrorLogCat true to save the error logs
*/
private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) {
val logcatProc: Process
try {
logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG)
} catch (e1: IOException) {
return
}
var reader: BufferedReader? = null
try {
val separator = System.getProperty("line.separator")
reader = BufferedReader(InputStreamReader(logcatProc.inputStream), BUFFER_SIZE)
var line = reader.readLine()
while (line != null) {
streamWriter.append(line)
streamWriter.append(separator)
line = reader.readLine()
}
} catch (e: IOException) {
Timber.e(e, "getLog fails with " + e.localizedMessage)
} finally {
if (reader != null) {
try {
reader.close()
} catch (e: IOException) {
Timber.e(e, "getLog fails with " + e.localizedMessage)
}
}
}
}
//==============================================================================================================
// File compression management
//==============================================================================================================
/**
* GZip a file
*
* @param fin the input file
* @return the gzipped file
*/
private fun compressFile(fin: File): File? {
Timber.d("## compressFile() : compress " + fin.name)
val dstFile = File(fin.parent, fin.name + ".gz")
if (dstFile.exists()) {
dstFile.delete()
}
var fos: FileOutputStream? = null
var gos: GZIPOutputStream? = null
var inputStream: InputStream? = null
try {
fos = FileOutputStream(dstFile)
gos = GZIPOutputStream(fos)
inputStream = FileInputStream(fin)
val buffer = ByteArray(2048)
var n = inputStream.read(buffer)
while (n != -1) {
gos.write(buffer, 0, n)
n = inputStream.read(buffer)
}
gos.close()
inputStream.close()
Timber.d("## compressFile() : " + fin.length() + " compressed to " + dstFile.length() + " bytes")
return dstFile
} catch (e: Exception) {
Timber.e(e, "## compressFile() failed " + e.message)
} catch (oom: OutOfMemoryError) {
Timber.e(oom, "## compressFile() failed " + oom.message)
} finally {
try {
fos?.close()
gos?.close()
inputStream?.close()
} catch (e: Exception) {
Timber.e(e, "## compressFile() failed to close inputStream " + e.message)
}
}
return null
}
}

View File

@ -0,0 +1,300 @@
/*
* Copyright 2017 Vector Creations Ltd
* Copyright 2018 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.rageshake;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import okhttp3.Headers;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.internal.Util;
import okio.Buffer;
import okio.BufferedSink;
import okio.ByteString;
// simplified version of MultipartBody (OkHttp 3.6.0)
public class BugReporterMultipartBody extends RequestBody {
/**
* Listener
*/
public interface WriteListener {
/**
* Upload listener
*
* @param totalWritten total written bytes
* @param contentLength content length
*/
void onWrite(long totalWritten, long contentLength);
}
private static final MediaType FORM = MediaType.parse("multipart/form-data");
private static final byte[] COLONSPACE = {':', ' '};
private static final byte[] CRLF = {'\r', '\n'};
private static final byte[] DASHDASH = {'-', '-'};
private final ByteString mBoundary;
private final MediaType mContentType;
private final List<Part> mParts;
private long mContentLength = -1L;
// listener
private WriteListener mWriteListener;
//
private List<Long> mContentLengthSize = null;
private BugReporterMultipartBody(ByteString boundary, List<Part> parts) {
mBoundary = boundary;
mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8());
mParts = Util.immutableList(parts);
}
@Override
public MediaType contentType() {
return mContentType;
}
@Override
public long contentLength() throws IOException {
long result = mContentLength;
if (result != -1L) return result;
return mContentLength = writeOrCountBytes(null, true);
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
writeOrCountBytes(sink, false);
}
/**
* Set the listener
*
* @param listener the
*/
public void setWriteListener(WriteListener listener) {
mWriteListener = listener;
}
/**
* Warn the listener that some bytes have been written
*
* @param totalWrittenBytes the total written bytes
*/
private void onWrite(long totalWrittenBytes) {
if ((null != mWriteListener) && (mContentLength > 0)) {
mWriteListener.onWrite(totalWrittenBytes, mContentLength);
}
}
/**
* Either writes this request to {@code sink} or measures its content length. We have one method
* do double-duty to make sure the counting and content are consistent, particularly when it comes
* to awkward operations like measuring the encoded length of header strings, or the
* length-in-digits of an encoded integer.
*/
private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException {
long byteCount = 0L;
Buffer byteCountBuffer = null;
if (countBytes) {
sink = byteCountBuffer = new Buffer();
mContentLengthSize = new ArrayList<>();
}
for (int p = 0, partCount = mParts.size(); p < partCount; p++) {
Part part = mParts.get(p);
Headers headers = part.headers;
RequestBody body = part.body;
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(CRLF);
if (headers != null) {
for (int h = 0, headerCount = headers.size(); h < headerCount; h++) {
sink.writeUtf8(headers.name(h))
.write(COLONSPACE)
.writeUtf8(headers.value(h))
.write(CRLF);
}
}
MediaType contentType = body.contentType();
if (contentType != null) {
sink.writeUtf8("Content-Type: ")
.writeUtf8(contentType.toString())
.write(CRLF);
}
int contentLength = (int) body.contentLength();
if (contentLength != -1) {
sink.writeUtf8("Content-Length: ")
.writeUtf8(contentLength + "")
.write(CRLF);
} else if (countBytes) {
// We can't measure the body's size without the sizes of its components.
byteCountBuffer.clear();
return -1L;
}
sink.write(CRLF);
if (countBytes) {
byteCount += contentLength;
mContentLengthSize.add(byteCount);
} else {
body.writeTo(sink);
// warn the listener of upload progress
// sink.buffer().size() does not give the right value
// assume that some data are popped
if ((null != mContentLengthSize) && (p < mContentLengthSize.size())) {
onWrite(mContentLengthSize.get(p));
}
}
sink.write(CRLF);
}
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(DASHDASH);
sink.write(CRLF);
if (countBytes) {
byteCount += byteCountBuffer.size();
byteCountBuffer.clear();
}
return byteCount;
}
private static void appendQuotedString(StringBuilder target, String key) {
target.append('"');
for (int i = 0, len = key.length(); i < len; i++) {
char ch = key.charAt(i);
switch (ch) {
case '\n':
target.append("%0A");
break;
case '\r':
target.append("%0D");
break;
case '"':
target.append("%22");
break;
default:
target.append(ch);
break;
}
}
target.append('"');
}
public static final class Part {
public static Part create(Headers headers, RequestBody body) {
if (body == null) {
throw new NullPointerException("body == null");
}
if (headers != null && headers.get("Content-Type") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Type");
}
if (headers != null && headers.get("Content-Length") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Length");
}
return new Part(headers, body);
}
public static Part createFormData(String name, String value) {
return createFormData(name, null, RequestBody.create(null, value));
}
public static Part createFormData(String name, String filename, RequestBody body) {
if (name == null) {
throw new NullPointerException("name == null");
}
StringBuilder disposition = new StringBuilder("form-data; name=");
appendQuotedString(disposition, name);
if (filename != null) {
disposition.append("; filename=");
appendQuotedString(disposition, filename);
}
return create(Headers.of("Content-Disposition", disposition.toString()), body);
}
final Headers headers;
final RequestBody body;
private Part(Headers headers, RequestBody body) {
this.headers = headers;
this.body = body;
}
}
public static final class Builder {
private final ByteString boundary;
private final List<Part> parts = new ArrayList<>();
public Builder() {
this(UUID.randomUUID().toString());
}
public Builder(String boundary) {
this.boundary = ByteString.encodeUtf8(boundary);
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String value) {
return addPart(Part.createFormData(name, value));
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String filename, RequestBody body) {
return addPart(Part.createFormData(name, filename, body));
}
/**
* Add a part to the body.
*/
public Builder addPart(Part part) {
if (part == null) throw new NullPointerException("part == null");
parts.add(part);
return this;
}
/**
* Assemble the specified parts into a request body.
*/
public BugReporterMultipartBody build() {
if (parts.isEmpty()) {
throw new IllegalStateException("Multipart body must have at least one part.");
}
return new BugReporterMultipartBody(boundary, parts);
}
}
}

Some files were not shown because too many files have changed in this diff Show More