Unverified Commit 972ca2cf authored by Pancor's avatar Pancor Committed by GitHub

Merge branch 'develop-2.x' into feature/save-unfinished-message

parents c16c4b0b 8ea28419
......@@ -3,6 +3,7 @@ apply plugin: 'io.fabric'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'realm-android'
android {
compileSdkVersion versions.compileSdk
......@@ -12,8 +13,8 @@ android {
applicationId "chat.rocket.android"
minSdkVersion 21
targetSdkVersion versions.targetSdk
versionCode 2000
versionName "2.0.0-alpha1"
versionCode 2001
versionName "2.0.0-beta1"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
multiDexEnabled true
}
......@@ -29,12 +30,16 @@ android {
buildTypes {
release {
buildConfigField "String", "REQUIRED_SERVER_VERSION", '"0.62.0"'
buildConfigField "String", "RECOMMENDED_SERVER_VERSION", '"0.63.0"'
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
buildConfigField "String", "REQUIRED_SERVER_VERSION", '"0.62.0"'
buildConfigField "String", "RECOMMENDED_SERVER_VERSION", '"0.63.0"'
applicationIdSuffix ".dev"
}
}
......@@ -59,6 +64,7 @@ dependencies {
implementation libraries.constraintLayout
implementation libraries.cardView
implementation libraries.flexbox
implementation libraries.customTabs
implementation libraries.androidKtx
......@@ -110,8 +116,6 @@ dependencies {
androidTestImplementation(libraries.expressoCore, {
exclude group: 'com.android.support', module: 'support-annotations'
})
implementation 'com.android.support:customtabs:27.0.2'
}
kotlin {
......
......@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
......@@ -35,6 +35,10 @@
</intent-filter>
</activity>
<activity
android:name=".server.ui.ChangeServerActivity"
android:theme="@style/AuthenticationTheme" />
<activity
android:name=".main.ui.MainActivity"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden"
......@@ -50,6 +54,11 @@
android:windowSoftInputMode="adjustResize|stateAlwaysHidden"
android:theme="@style/AppTheme" />
<activity
android:name=".webview.oauth.ui.OauthWebViewActivity"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden"
android:theme="@style/AppTheme" />
<activity
android:name=".chatroom.ui.ChatRoomActivity"
android:windowSoftInputMode="adjustResize"
......
app/src/main/ic_launcher-web.png

20 KB | W: | H:

app/src/main/ic_launcher-web.png

39.1 KB | W: | H:

app/src/main/ic_launcher-web.png
app/src/main/ic_launcher-web.png
app/src/main/ic_launcher-web.png
app/src/main/ic_launcher-web.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -3,30 +3,48 @@ package chat.rocket.android.app
import android.app.Activity
import android.app.Application
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.SharedPreferences
import androidx.content.edit
import chat.rocket.android.BuildConfig
import chat.rocket.android.app.migration.RealmMigration
import chat.rocket.android.app.migration.RocketChatLibraryModule
import chat.rocket.android.app.migration.RocketChatServerModule
import chat.rocket.android.app.migration.model.RealmBasedServerInfo
import chat.rocket.android.app.migration.model.RealmPublicSetting
import chat.rocket.android.app.migration.model.RealmSession
import chat.rocket.android.app.migration.model.RealmUser
import chat.rocket.android.authentication.domain.model.toToken
import chat.rocket.android.dagger.DaggerAppComponent
import chat.rocket.android.helper.CrashlyticsTree
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.MultiServerTokenRepository
import chat.rocket.android.server.domain.SettingsRepository
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.widget.emoji.EmojiRepository
import chat.rocket.common.model.Token
import chat.rocket.core.TokenRepository
import chat.rocket.core.model.Value
import com.crashlytics.android.Crashlytics
import com.crashlytics.android.core.CrashlyticsCore
import com.facebook.drawee.backends.pipeline.DraweeConfig
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.imagepipeline.core.ImagePipelineConfig
import com.jakewharton.threetenabp.AndroidThreeTen
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasActivityInjector
import dagger.android.HasServiceInjector
import dagger.android.*
import io.fabric.sdk.android.Fabric
import io.realm.Realm
import io.realm.RealmConfiguration
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
import timber.log.Timber
import java.lang.ref.WeakReference
import javax.inject.Inject
class RocketChatApplication : Application(), HasActivityInjector, HasServiceInjector {
class RocketChatApplication : Application(), HasActivityInjector, HasServiceInjector,
HasBroadcastReceiverInjector {
@Inject
lateinit var activityDispatchingAndroidInjector: DispatchingAndroidInjector<Activity>
......@@ -34,6 +52,9 @@ class RocketChatApplication : Application(), HasActivityInjector, HasServiceInje
@Inject
lateinit var serviceDispatchingAndroidInjector: DispatchingAndroidInjector<Service>
@Inject
lateinit var broadcastReceiverInjector: DispatchingAndroidInjector<BroadcastReceiver>
@Inject
lateinit var imagePipelineConfig: ImagePipelineConfig
@Inject
......@@ -48,14 +69,25 @@ class RocketChatApplication : Application(), HasActivityInjector, HasServiceInje
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var tokenRepository: TokenRepository
@Inject
lateinit var accountRepository: AccountsRepository
@Inject
lateinit var saveCurrentServerRepository: SaveCurrentServerInteractor
@Inject
lateinit var prefs: SharedPreferences
@Inject
lateinit var getAccountsInteractor: GetAccountsInteractor
@Inject
lateinit var localRepository: LocalRepository
override fun onCreate() {
super.onCreate()
DaggerAppComponent.builder().application(this).build().inject(this)
// TODO - remove this when we have a proper service handling connection...
initCurrentServer()
// TODO - remove this on the future, temporary migration stuff for pre-release versions.
migrateInternalTokens()
context = WeakReference(applicationContext)
AndroidThreeTen.init(this)
EmojiRepository.load(this)
......@@ -63,16 +95,142 @@ class RocketChatApplication : Application(), HasActivityInjector, HasServiceInje
setupCrashlytics()
setupFresco()
setupTimber()
// TODO - remove this and all realm stuff when we got to 80% in 2.0
try {
if (!localRepository.hasMigrated()) {
migrateFromLegacy()
}
} catch (ex: Exception) {
Timber.d(ex, "Error migrating old accounts")
}
}
// TODO - remove this when we have a proper service handling connection...
private fun initCurrentServer() {
val currentServer = getCurrentServerInteractor.get()
val serverToken = currentServer?.let { multiServerRepository.get(currentServer) }
val settings = currentServer?.let { settingsRepository.get(currentServer) }
if (currentServer != null && serverToken != null && settings != null) {
tokenRepository.save(Token(serverToken.userId, serverToken.authToken))
private fun migrateFromLegacy() {
Realm.init(this)
val serveListConfiguration = RealmConfiguration.Builder()
.name("server.list.realm")
.schemaVersion(6)
.migration(RealmMigration())
.modules(RocketChatServerModule())
.build()
val serverRealm = Realm.getInstance(serveListConfiguration)
val serversInfoList = serverRealm.where(RealmBasedServerInfo::class.java).findAll().toList()
serversInfoList.forEach { server ->
val hostname = server.hostname
val url = if (server.insecure) "http://$hostname" else "https://$hostname"
val config = RealmConfiguration.Builder()
.name("${server.hostname}.realm")
.schemaVersion(6)
.migration(RealmMigration())
.modules(RocketChatLibraryModule())
.build()
val realm = Realm.getInstance(config)
val user = realm.where(RealmUser::class.java)
.isNotEmpty(RealmUser.EMAILS).findFirst()
val session = realm.where(RealmSession::class.java).findFirst()
migratePublicSettings(url, realm)
if (user != null && session != null) {
val authToken = session.token
settingsRepository.get(url)
migrateServerInfo(url, authToken!!, settingsRepository.get(url), user)
}
realm.close()
}
migrateCurrentServer(serversInfoList)
serverRealm.close()
localRepository.setMigrated(true)
}
private fun migrateServerInfo(url: String, authToken: String, settings: PublicSettings, user: RealmUser) {
val userId = user._id
val avatar = UrlHelper.getAvatarUrl(url, user.username!!)
val icon = settings.favicon()?.let {
UrlHelper.getServerLogoUrl(url, it)
}
val logo = settings.wideTile()?.let {
UrlHelper.getServerLogoUrl(url, it)
}
val account = Account(url, icon, logo, user.username!!, avatar)
launch(CommonPool) {
tokenRepository.save(url, Token(userId!!, authToken))
accountRepository.save(account)
}
}
private fun migratePublicSettings(url: String, realm: Realm) {
val settings = realm.where(RealmPublicSetting::class.java).findAll()
val serverSettings = hashMapOf<String, Value<Any>>()
settings.toList().forEach { setting ->
val type = setting.type!!
val value = setting.value!!
val convertedSetting = when (type) {
"string" -> Value(value)
"language" -> Value(value)
"boolean" -> Value(value.toBoolean())
"int" -> try {
Value(value.toInt())
} catch (ex: NumberFormatException) {
Value(0)
}
else -> null // ignore
}
if (convertedSetting != null) {
val id = setting._id!!
serverSettings.put(id, convertedSetting)
}
}
settingsRepository.save(url, serverSettings)
}
private fun migrateCurrentServer(serversList: List<RealmBasedServerInfo>) {
if (getCurrentServerInteractor.get() == null) {
var currentServer = getSharedPreferences("cache", Context.MODE_PRIVATE)
.getString("KEY_SELECTED_SERVER_HOSTNAME", null)
currentServer = if (serversList.isNotEmpty()) {
val server = serversList.find { it.hostname == currentServer }
val hostname = server!!.hostname
if (server.insecure) {
"http://$hostname"
} else {
"https://$hostname"
}
} else {
"http://$currentServer"
}
saveCurrentServerRepository.save(currentServer)
}
}
private fun migrateInternalTokens() {
if (!prefs.getBoolean(INTERNAL_TOKEN_MIGRATION_NEEDED, true)) {
Timber.d("Tokens already migrated")
return
}
getCurrentServerInteractor.get()?.let { serverUrl ->
multiServerRepository.get(serverUrl)?.let { token ->
tokenRepository.save(serverUrl, Token(token.userId, token.authToken))
}
}
runBlocking {
getAccountsInteractor.get().forEach { account ->
multiServerRepository.get(account.serverUrl)?.let { token ->
tokenRepository.save(account.serverUrl, token.toToken())
}
}
}
prefs.edit { putBoolean(INTERNAL_TOKEN_MIGRATION_NEEDED, false) }
}
private fun setupCrashlytics() {
......@@ -99,4 +257,23 @@ class RocketChatApplication : Application(), HasActivityInjector, HasServiceInje
override fun serviceInjector(): AndroidInjector<Service> {
return serviceDispatchingAndroidInjector
}
}
\ No newline at end of file
override fun broadcastReceiverInjector(): AndroidInjector<BroadcastReceiver> {
return broadcastReceiverInjector
}
companion object {
var context: WeakReference<Context>? = null
fun getAppContext(): Context? {
return context?.get()
}
}
}
private fun LocalRepository.setMigrated(migrated: Boolean) {
save(LocalRepository.MIGRATION_FINISHED_KEY, migrated)
}
private fun LocalRepository.hasMigrated() = getBoolean(LocalRepository.MIGRATION_FINISHED_KEY)
private const val INTERNAL_TOKEN_MIGRATION_NEEDED = "INTERNAL_TOKEN_MIGRATION_NEEDED"
\ No newline at end of file
package chat.rocket.android.app.migration
import chat.rocket.android.BuildConfig
import chat.rocket.android.app.migration.model.RealmUser
import io.realm.DynamicRealm
import io.realm.RealmMigration
class RealmMigration : RealmMigration {
override fun migrate(dynamicRealm: DynamicRealm, oldVersion: Long, newVersion: Long) {
var oldVersion = oldVersion
val schema = dynamicRealm.schema
if (oldVersion == 0L) {
// NOOP
oldVersion++
}
if (oldVersion == 1L) {
oldVersion++
}
if (oldVersion == 2L) {
oldVersion++
}
if (oldVersion == 3L) {
oldVersion++
}
if (oldVersion == 4L) {
oldVersion++
}
if (oldVersion == 5L) {
val userSchema = schema.get("RealmUser")
try {
userSchema?.addField(RealmUser.NAME, String::class.java)
} catch (e: IllegalArgumentException) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
// ignore; it makes here if the schema for this model was already update before without migration
}
}
}
// hack around to avoid "new different configuration cannot access the same file" error
override fun hashCode(): Int {
return 37
}
override fun equals(o: Any?): Boolean {
return o is chat.rocket.android.app.migration.RealmMigration
}
// end hack
}
\ No newline at end of file
package chat.rocket.android.app.migration
import io.realm.annotations.RealmModule
@RealmModule(library = true, allClasses = true)
class RocketChatLibraryModule
\ No newline at end of file
package chat.rocket.android.app.migration
import chat.rocket.android.app.migration.model.RealmBasedServerInfo
import io.realm.annotations.RealmModule
@RealmModule(library = true, classes = arrayOf(RealmBasedServerInfo::class))
class RocketChatServerModule
\ No newline at end of file
package chat.rocket.android.app.migration.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
open class RealmBasedServerInfo : RealmObject() {
@PrimaryKey
@JvmField var hostname: String? = null
@JvmField var name: String? = null
@JvmField var session: String? = null
@JvmField var insecure: Boolean = false
}
\ No newline at end of file
package chat.rocket.android.app.migration.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
open class RealmEmail : RealmObject() {
@PrimaryKey
@JvmField
var address: String? = null
@JvmField
var verified: Boolean = false
}
\ No newline at end of file
package chat.rocket.android.app.migration.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
open class RealmPreferences : RealmObject() {
@PrimaryKey
@JvmField
var id: String? = null
@JvmField
var newRoomNotification: String? = null
@JvmField
var newMessageNotification: String? = null
@JvmField
var useEmojis: Boolean = false
@JvmField
var convertAsciiEmoji: Boolean = false
@JvmField
var saveMobileBandwidth: Boolean = false
@JvmField
var collapseMediaByDefault: Boolean = false
@JvmField
var unreadRoomsMode: Boolean = false
@JvmField
var autoImageLoad: Boolean = false
@JvmField
var emailNotificationMode: String? = null
@JvmField
var unreadAlert: Boolean = false
@JvmField
var desktopNotificationDuration: Int = 0
@JvmField
var viewMode: Int = 0
@JvmField
var hideUsernames: Boolean = false
@JvmField
var hideAvatars: Boolean = false
@JvmField
var hideFlexTab: Boolean = false
}
\ No newline at end of file
package chat.rocket.android.app.migration.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
open class RealmPublicSetting : RealmObject() {
@PrimaryKey
@JvmField
var _id: String? = null
@JvmField
var group: String? = null
@JvmField
var type: String? = null
@JvmField
var value: String? = null
@JvmField
var _updatedAt: Long = 0
@JvmField
var meta: String? = null
}
\ No newline at end of file
package chat.rocket.android.app.migration.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
open class RealmSession : RealmObject() {
@JvmField
@PrimaryKey
var sessionId: Int = 0 //only 0 is used!
@JvmField
var token: String? = null
@JvmField
var tokenVerified: Boolean = false
@JvmField
var error: String? = null
}
\ No newline at end of file
package chat.rocket.android.app.migration.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
open class RealmSettings : RealmObject() {
@PrimaryKey
@JvmField
var id: String? = null
@JvmField
var preferences: RealmPreferences? = null
}
\ No newline at end of file
package chat.rocket.android.app.migration.model
import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
open class RealmUser : RealmObject() {
companion object {
const val ID = "_id"
const val NAME = "name"
const val USERNAME = "username"
const val STATUS = "status"
const val UTC_OFFSET = "utcOffset"
const val EMAILS = "emails"
const val SETTINGS = "settings"
const val STATUS_ONLINE = "online"
const val STATUS_BUSY = "busy"
const val STATUS_AWAY = "away"
const val STATUS_OFFLINE = "offline"
}
@PrimaryKey
@JvmField
var _id: String? = null
@JvmField
var name: String? = null
@JvmField
var username: String? = null
@JvmField
var status: String? = null
@JvmField
var utcOffset: Double = 0.toDouble()
@JvmField
var emails: RealmList<RealmEmail>? = null
@JvmField
var settings: RealmSettings? = null
}
\ No newline at end of file
package chat.rocket.android.authentication.di
import android.content.Context
import chat.rocket.android.authentication.presentation.AuthenticationNavigator
import chat.rocket.android.authentication.ui.AuthenticationActivity
import chat.rocket.android.dagger.scope.PerActivity
......@@ -12,5 +11,5 @@ class AuthenticationModule {
@Provides
@PerActivity
fun provideAuthenticationNavigator(activity: AuthenticationActivity, context: Context) = AuthenticationNavigator(activity, context)
fun provideAuthenticationNavigator(activity: AuthenticationActivity) = AuthenticationNavigator(activity)
}
\ No newline at end of file
package chat.rocket.android.authentication.domain.model
import chat.rocket.common.model.Token
import se.ansman.kotshi.JsonSerializable
@JsonSerializable
data class TokenModel(val userId: String, val authToken: String)
\ No newline at end of file
data class TokenModel(val userId: String, val authToken: String)
fun TokenModel.toToken() = Token(userId, authToken)
\ No newline at end of file
package chat.rocket.android.authentication.infraestructure
import chat.rocket.common.model.Token
import chat.rocket.core.TokenRepository
class MemoryTokenRepository : TokenRepository {
var savedToken: Token? = null
override fun get(): Token? {
return savedToken
}
override fun save(token: Token) {
savedToken = token
}
}
\ No newline at end of file
package chat.rocket.android.authentication.infraestructure
import android.content.SharedPreferences
import androidx.content.edit
import chat.rocket.android.authentication.domain.model.TokenModel
import chat.rocket.android.server.domain.TokenRepository
import chat.rocket.common.model.Token
import com.squareup.moshi.Moshi
import timber.log.Timber
class SharedPreferencesTokenRepository(private val prefs: SharedPreferences, moshi: Moshi) : TokenRepository {
private var servers = prefs.getStringSet(KEY_SERVERS, emptySet()).toMutableSet()
private var currentUrl: String? = null
private var currentToken: Token? = null
private val adapter = moshi.adapter<TokenModel>(TokenModel::class.java)
override fun get(url: String): Token? {
if (currentToken != null && url == currentUrl) {
return currentToken
}
try {
prefs.getString(tokenKey(url), null)?.let { tokenStr ->
val model = adapter.fromJson(tokenStr)
model?.let {
val token = Token(model.userId, model.authToken)
currentToken = token
currentUrl = url
}
}
} catch (ex: Exception) {
Timber.d(ex, "Error parsing token for ${tokenKey(url)}")
ex.printStackTrace()
}
return currentToken
}
override fun save(url: String, token: Token) {
try {
val model = TokenModel(token.userId, token.authToken)
val str = adapter.toJson(model)
servers.add(url)
prefs.edit {
putString(tokenKey(url), str)
putStringSet(KEY_SERVERS, servers)
}
currentToken = token
currentUrl = url
} catch (ex: Exception) {
Timber.d(ex, "Error saving token for ${tokenKey(url)}")
ex.printStackTrace()
}
}
override fun remove(url: String) {
servers.remove(url)
prefs.edit {
remove(url)
putStringSet(KEY_SERVERS, servers)
}
}
override fun clear() {
servers.forEach { server ->
prefs.edit { remove(server) }
}
servers.clear()
prefs.edit {
remove(KEY_SERVERS)
}
}
private fun tokenKey(url: String) = "$KEY_TOKEN$url"
}
private const val KEY_TOKEN = "KEY_TOKEN_"
private const val KEY_SERVERS = "KEY_SERVERS"
\ No newline at end of file
package chat.rocket.android.authentication.login.presentation
import chat.rocket.android.authentication.domain.model.TokenModel
import chat.rocket.android.BuildConfig
import chat.rocket.android.authentication.presentation.AuthenticationNavigator
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.helper.NetworkHelper
import chat.rocket.android.helper.OauthHelper
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.util.extensions.generateRandomString
import chat.rocket.android.util.extensions.isEmailValid
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.android.server.presentation.CheckServerPresenter
import chat.rocket.android.util.VersionInfo
import chat.rocket.android.util.extensions.*
import chat.rocket.common.RocketChatException
import chat.rocket.common.RocketChatTwoFactorException
import chat.rocket.common.model.Token
import chat.rocket.common.util.ifNull
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.*
import chat.rocket.core.model.Myself
import kotlinx.coroutines.experimental.delay
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
private const val TYPE_LOGIN_USER_EMAIL = 0
private const val TYPE_LOGIN_CAS = 1
private const val TYPE_LOGIN_OAUTH = 2
private const val SERVICE_NAME_GITHUB = "github"
private const val SERVICE_NAME_GOOGLE = "google"
private const val SERVICE_NAME_LINKEDIN = "linkedin"
private const val SERVICE_NAME_GILAB = "gitlab"
class LoginPresenter @Inject constructor(private val view: LoginView,
private val strategy: CancelStrategy,
private val navigator: AuthenticationNavigator,
private val multiServerRepository: MultiServerTokenRepository,
private val tokenRepository: TokenRepository,
private val localRepository: LocalRepository,
private val settingsInteractor: GetSettingsInteractor,
private val serverInteractor: GetCurrentServerInteractor,
factory: RocketChatClientFactory) {
private val getAccountsInteractor: GetAccountsInteractor,
settingsInteractor: GetSettingsInteractor,
serverInteractor: GetCurrentServerInteractor,
private val saveAccountInteractor: SaveAccountInteractor,
private val factory: RocketChatClientFactory)
: CheckServerPresenter(strategy, factory.create(serverInteractor.get()!!), view) {
// TODO - we should validate the current server when opening the app, and have a nonnull get()
private val client: RocketChatClient = factory.create(serverInteractor.get()!!)
private val currentServer = serverInteractor.get()!!
private val client: RocketChatClient = factory.create(currentServer)
private val settings: PublicSettings = settingsInteractor.get(currentServer)
private lateinit var usernameOrEmail: String
private lateinit var password: String
private lateinit var credentialToken: String
private lateinit var credentialSecret: String
fun setupView() {
val server = serverInteractor.get()
if (server == null) {
navigator.toServerScreen()
return
setupLoginView()
setupUserRegistrationView()
setupCasView()
setupOauthServicesView()
checkServerInfo()
}
fun authenticateWithUserAndPassword(usernameOrEmail: String, password: String) {
when {
usernameOrEmail.isBlank() -> {
view.alertWrongUsernameOrEmail()
}
password.isEmpty() -> {
view.alertWrongPassword()
}
else -> {
this.usernameOrEmail = usernameOrEmail
this.password = password
doAuthentication(TYPE_LOGIN_USER_EMAIL)
}
}
val settings = settingsInteractor.get(server)
}
fun authenticateWithCas(token: String) {
credentialToken = token
doAuthentication(TYPE_LOGIN_CAS)
}
fun authenticateWithOauth(token: String, secret: String) {
credentialToken = token
credentialSecret = secret
doAuthentication(TYPE_LOGIN_OAUTH)
}
fun signup() = navigator.toSignUp()
private fun setupLoginView() {
if (settings.isLoginFormEnabled()) {
view.showFormView()
view.setupLoginButtonListener()
......@@ -46,134 +98,148 @@ class LoginPresenter @Inject constructor(private val view: LoginView,
} else {
view.hideFormView()
}
}
if (settings.isRegistrationEnabledForNewUsers()) {
view.showSignUpView()
view.setupSignUpView()
}
private fun setupCasView() {
if (settings.isCasAuthenticationEnabled()) {
val token = generateRandomString(17)
view.setupCasButtonListener(UrlHelper.getCasUrl(settings.casLoginUrl(), server, token), token)
view.setupCasButtonListener(UrlHelper.getCasUrl(settings.casLoginUrl(), currentServer, token), token)
view.showCasButton()
}
}
var totalSocialAccountsEnabled = 0
if (settings.isFacebookAuthenticationEnabled()) {
view.enableLoginByFacebook()
totalSocialAccountsEnabled++
}
if (settings.isGithubAuthenticationEnabled()) {
view.enableLoginByGithub()
totalSocialAccountsEnabled++
}
if (settings.isGoogleAuthenticationEnabled()) {
view.enableLoginByGoogle()
totalSocialAccountsEnabled++
}
if (settings.isLinkedinAuthenticationEnabled()) {
view.enableLoginByLinkedin()
totalSocialAccountsEnabled++
}
if (settings.isMeteorAuthenticationEnabled()) {
view.enableLoginByMeteor()
totalSocialAccountsEnabled++
}
if (settings.isTwitterAuthenticationEnabled()) {
view.enableLoginByTwitter()
totalSocialAccountsEnabled++
}
if (settings.isGitlabAuthenticationEnabled()) {
view.enableLoginByGitlab()
totalSocialAccountsEnabled++
}
if (totalSocialAccountsEnabled > 0) {
view.showOauthView()
if (totalSocialAccountsEnabled > 3) {
view.setupFabListener()
}
private fun setupUserRegistrationView() {
if (settings.isRegistrationEnabledForNewUsers()) {
view.showSignUpView()
view.setupSignUpView()
}
}
fun authenticate(usernameOrEmail: String, password: String) {
val server = serverInteractor.get()
when {
server == null -> {
navigator.toServerScreen()
}
usernameOrEmail.isBlank() -> {
view.alertWrongUsernameOrEmail()
}
password.isEmpty() -> {
view.alertWrongPassword()
}
else -> {
launchUI(strategy) {
if (NetworkHelper.hasInternetAccess()) {
view.disableUserInput()
view.showLoading()
try {
val token = if (usernameOrEmail.isEmailValid()) {
client.loginWithEmail(usernameOrEmail, password)
} else {
val settings = settingsInteractor.get(server)
if (settings.isLdapAuthenticationEnabled()) {
client.loginWithLdap(usernameOrEmail, password)
} else {
client.login(usernameOrEmail, password)
}
}
private fun setupOauthServicesView() {
launchUI(strategy) {
try {
val services = client.settingsOauth().services
if (services.isNotEmpty()) {
val state = "{\"loginStyle\":\"popup\",\"credentialToken\":\"${generateRandomString(40)}\",\"isCordova\":true}".encodeToBase64()
var totalSocialAccountsEnabled = 0
saveToken(server, TokenModel(token.userId, token.authToken), client.me().username)
registerPushToken()
navigator.toChatList()
} catch (exception: RocketChatException) {
when (exception) {
is RocketChatTwoFactorException -> {
navigator.toTwoFA(usernameOrEmail, password)
}
else -> {
exception.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
}
}
} finally {
view.hideLoading()
view.enableUserInput()
if (settings.isFacebookAuthenticationEnabled()) {
// //TODO: Remove until we have this implemented
// view.enableLoginByFacebook()
// totalSocialAccountsEnabled++
}
if (settings.isGithubAuthenticationEnabled()) {
val clientId = getOauthClientId(services, SERVICE_NAME_GITHUB)
if (clientId != null) {
view.setupGithubButtonListener(OauthHelper.getGithubOauthUrl(clientId, state), state)
view.enableLoginByGithub()
totalSocialAccountsEnabled++
}
}
if (settings.isGoogleAuthenticationEnabled()) {
val clientId = getOauthClientId(services, SERVICE_NAME_GOOGLE)
if (clientId != null) {
view.setupGoogleButtonListener(OauthHelper.getGoogleOauthUrl(clientId, currentServer, state), state)
view.enableLoginByGoogle()
totalSocialAccountsEnabled++
}
}
if (settings.isLinkedinAuthenticationEnabled()) {
val clientId = getOauthClientId(services, SERVICE_NAME_LINKEDIN)
if (clientId != null) {
view.setupLinkedinButtonListener(OauthHelper.getLinkedinOauthUrl(clientId, currentServer, state), state)
view.enableLoginByLinkedin()
totalSocialAccountsEnabled++
}
}
if (settings.isMeteorAuthenticationEnabled()) {
//TODO: Remove until we have this implemented
// view.enableLoginByMeteor()
// totalSocialAccountsEnabled++
}
if (settings.isTwitterAuthenticationEnabled()) {
//TODO: Remove until we have this implemented
// view.enableLoginByTwitter()
// totalSocialAccountsEnabled++
}
if (settings.isGitlabAuthenticationEnabled()) {
val clientId = getOauthClientId(services, SERVICE_NAME_GILAB)
if (clientId != null) {
view.setupGitlabButtonListener(OauthHelper.getGitlabOauthUrl(clientId, currentServer, state), state)
view.enableLoginByGitlab()
totalSocialAccountsEnabled++
}
}
if (totalSocialAccountsEnabled > 0) {
view.enableOauthView()
if (totalSocialAccountsEnabled > 3) {
view.setupFabListener()
}
} else {
view.showNoInternetConnection()
view.disableOauthView()
}
} else {
view.disableOauthView()
}
} catch (exception: RocketChatException) {
Timber.e(exception)
view.disableOauthView()
}
}
}
fun authenticateWithCas(casToken: String) {
private fun doAuthentication(loginType: Int) {
launchUI(strategy) {
if (NetworkHelper.hasInternetAccess()) {
view.disableUserInput()
view.showLoading()
try {
val server = serverInteractor.get()
if (server != null) {
delay(3, TimeUnit.SECONDS)
val token = client.loginWithCas(casToken)
saveToken(server, TokenModel(token.userId, token.authToken), client.me().username)
val token = when (loginType) {
TYPE_LOGIN_USER_EMAIL -> {
if (usernameOrEmail.isEmail()) {
client.loginWithEmail(usernameOrEmail, password)
} else {
if (settings.isLdapAuthenticationEnabled()) {
client.loginWithLdap(usernameOrEmail, password)
} else {
client.login(usernameOrEmail, password)
}
}
}
TYPE_LOGIN_CAS -> {
delay(3, TimeUnit.SECONDS)
client.loginWithCas(credentialToken)
}
TYPE_LOGIN_OAUTH -> {
client.loginWithOauth(credentialToken, credentialSecret)
}
else -> {
throw IllegalStateException("Expected TYPE_LOGIN_USER_EMAIL, TYPE_LOGIN_CAS or TYPE_LOGIN_OAUTH")
}
}
val username = client.me().username
if (username != null) {
localRepository.save(LocalRepository.CURRENT_USERNAME_KEY, username)
saveAccount(username)
saveToken(token)
registerPushToken()
navigator.toChatList()
} else {
navigator.toServerScreen()
} else if (loginType == TYPE_LOGIN_OAUTH) {
view.alertRequiresUsername()
}
} catch (exception: RocketChatException) {
exception.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
when (exception) {
is RocketChatTwoFactorException -> {
navigator.toTwoFA(usernameOrEmail, password)
}
else -> {
exception.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
}
}
} finally {
view.hideLoading()
......@@ -185,18 +251,32 @@ class LoginPresenter @Inject constructor(private val view: LoginView,
}
}
fun signup() = navigator.toSignUp()
private fun getOauthClientId(listMap: List<Map<String, String>>, serviceName: String): String? {
return listMap.find { map -> map.containsValue(serviceName) }
?.get("appId")
}
private suspend fun saveAccount(username: String) {
val icon = settings.favicon()?.let {
UrlHelper.getServerLogoUrl(currentServer, it)
}
val logo = settings.wideTile()?.let {
UrlHelper.getServerLogoUrl(currentServer, it)
}
val thumb = UrlHelper.getAvatarUrl(currentServer, username)
val account = Account(currentServer, icon, logo, username, thumb)
saveAccountInteractor.save(account)
}
private suspend fun saveToken(server: String, tokenModel: TokenModel, username: String?) {
multiServerRepository.save(server, tokenModel)
localRepository.save(LocalRepository.USERNAME_KEY, username)
registerPushToken()
private fun saveToken(token: Token) {
tokenRepository.save(currentServer, token)
}
private suspend fun registerPushToken() {
localRepository.get(LocalRepository.KEY_PUSH_TOKEN)?.let {
client.registerPushToken(it)
client.registerPushToken(it, getAccountsInteractor.get(), factory)
}
// TODO: Schedule push token registering when it comes up null
// TODO: When the push token is null, at some point we should receive it with
// onTokenRefresh() on FirebaseTokenService, we need to confirm it.
}
}
\ No newline at end of file
package chat.rocket.android.authentication.login.presentation
import chat.rocket.android.authentication.server.presentation.VersionCheckView
import chat.rocket.android.core.behaviours.InternetView
import chat.rocket.android.core.behaviours.LoadingView
import chat.rocket.android.core.behaviours.MessageView
interface LoginView : LoadingView, MessageView, InternetView {
interface LoginView : LoadingView, MessageView, InternetView, VersionCheckView {
/**
* Shows the form view (i.e the username/email and password fields) if it is enabled by the server settings.
......@@ -37,7 +38,7 @@ interface LoginView : LoadingView, MessageView, InternetView {
/**
* Shows the CAS button if the sign in/sign out via CAS protocol is enabled by the server settings.
*
* REMARK: We must set up the CAS button listener [setupCasButtonListener].
* REMARK: We must set up the CAS button listener before showing it [setupCasButtonListener].
*/
fun showCasButton()
......@@ -49,8 +50,8 @@ interface LoginView : LoadingView, MessageView, InternetView {
/**
* Setups the CAS button when tapped.
*
* @param casUrl The CAS URL to login/sign up with.
* @param casToken The requested Token sent to the CAS server.
* @param casUrl The CAS URL to authenticate with.
* @param casToken The requested token to be sent to the CAS server.
*/
fun setupCasButtonListener(casUrl: String, casToken: String)
......@@ -72,18 +73,18 @@ interface LoginView : LoadingView, MessageView, InternetView {
fun hideSignUpView()
/**
* Shows the oauth view if the login via social accounts is enabled by the server settings.
* Enables and shows the oauth view if there is login via social accounts enabled by the server settings.
*
* REMARK: We must show at maximum *three* social accounts views ([enableLoginByFacebook], [enableLoginByGithub], [enableLoginByGoogle],
* [enableLoginByLinkedin], [enableLoginByMeteor], [enableLoginByTwitter] or [enableLoginByGitlab]) for the oauth view.
* If the possibility of login via social accounts exceeds 3 different ways we should set up the FAB ([setupFabListener]) to show the remaining view(s).
*/
fun showOauthView()
fun enableOauthView()
/**
* Hides the oauth view.
* Disables and hides the Oauth view if there is not login via social accounts enabled by the server settings.
*/
fun hideOauthView()
fun disableOauthView()
/**
* Shows the login button.
......@@ -96,40 +97,80 @@ interface LoginView : LoadingView, MessageView, InternetView {
fun hideLoginButton()
/**
* Shows the "login by Facebook view if it is enabled by the server settings.
* Shows the "login by Facebook view if it is enable by the server settings.
*/
fun enableLoginByFacebook()
/**
* Shows the "login by Github" view if it is enabled by the server settings.
* Shows the "login by Github" view if it is enable by the server settings.
*
* REMARK: We must set up the Github button listener before enabling it [setupGithubButtonListener].
*/
fun enableLoginByGithub()
/**
* Shows the "login by Google" view if it is enabled by the server settings.
* Setups the Github button when tapped.
*
* @param githubUrl The Github OAuth URL to authenticate with.
* @param state A random string generated by the app, which you'll verify later (to protect against forgery attacks).
*/
fun setupGithubButtonListener(githubUrl: String, state: String)
/**
* Shows the "login by Google" view if it is enable by the server settings.
*
* REMARK: We must set up the Google button listener before enabling it [setupGoogleButtonListener].
*/
fun enableLoginByGoogle()
/**
* Shows the "login by Linkedin" view if it is enabled by the server settings.
* Setups the Google button when tapped.
*
* @param googleUrl The Google OAuth URL to authenticate with.
* @param state A random string generated by the app, which you'll verify later (to protect against forgery attacks).
*/
fun setupGoogleButtonListener(googleUrl: String, state: String)
/**
* Shows the "login by Linkedin" view if it is enable by the server settings.
*
* REMARK: We must set up the Linkedin button listener before enabling it [setupLinkedinButtonListener].
*/
fun enableLoginByLinkedin()
/**
* Shows the "login by Meteor" view if it is enabled by the server settings.
* Setups the Linkedin button when tapped.
*
* @param linkedinUrl The Linkedin OAuth URL to authenticate with.
* @param state A random string generated by the app, which you'll verify later (to protect against forgery attacks).
*/
fun setupLinkedinButtonListener(linkedinUrl: String, state: String)
/**
* Shows the "login by Meteor" view if it is enable by the server settings.
*/
fun enableLoginByMeteor()
/**
* Shows the "login by Twitter" view if it is enabled by the server settings.
* Shows the "login by Twitter" view if it is enable by the server settings.
*/
fun enableLoginByTwitter()
/**
* Shows the "login by Gitlab" view if it is enabled by the server settings.
* Shows the "login by Gitlab" view if it is enable by the server settings.
*
* REMARK: We must set up the Gitlab button listener before enabling it [setupGitlabButtonListener].
*/
fun enableLoginByGitlab()
/**
* Setups the Gitlab button when tapped.
*
* @param gitlabUrl The Gitlab OAuth URL to authenticate with.
* @param state A random string generated by the app, which you'll verify later (to protect against forgery attacks).
*/
fun setupGitlabButtonListener(gitlabUrl: String, state: String)
/**
* Setups the FloatingActionButton to show more social accounts views (expanding the oauth view interface to show the remaining view(s)).
*/
......@@ -146,4 +187,9 @@ interface LoginView : LoadingView, MessageView, InternetView {
* Alerts the user about a wrong inputted password.
*/
fun alertWrongPassword()
/**
* Alerts the user about the need of creating an username using the web app when creating an user through OAuth.
*/
fun alertRequiresUsername()
}
\ No newline at end of file
......@@ -2,6 +2,7 @@ package chat.rocket.android.authentication.login.ui
import DrawableHelper
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.Build
import android.os.Bundle
......@@ -13,21 +14,29 @@ import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.ImageButton
import android.widget.ScrollView
import android.widget.Toast
import chat.rocket.android.BuildConfig
import chat.rocket.android.R
import chat.rocket.android.authentication.login.presentation.LoginPresenter
import chat.rocket.android.authentication.login.presentation.LoginView
import chat.rocket.android.helper.KeyboardHelper
import chat.rocket.android.helper.TextHelper
import chat.rocket.android.util.extensions.*
import chat.rocket.android.webview.cas.ui.webViewIntent
import chat.rocket.android.webview.cas.ui.INTENT_CAS_TOKEN
import chat.rocket.android.webview.cas.ui.casWebViewIntent
import chat.rocket.android.webview.oauth.ui.INTENT_OAUTH_CREDENTIAL_SECRET
import chat.rocket.android.webview.oauth.ui.INTENT_OAUTH_CREDENTIAL_TOKEN
import chat.rocket.android.webview.oauth.ui.oauthWebViewIntent
import dagger.android.support.AndroidSupportInjection
import kotlinx.android.synthetic.main.fragment_authentication_log_in.*
import javax.inject.Inject
internal const val REQUEST_CODE_FOR_CAS = 1
internal const val REQUEST_CODE_FOR_OAUTH = 2
class LoginFragment : Fragment(), LoginView {
@Inject lateinit var presenter: LoginPresenter
private var isOauthViewEnable = false
private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener {
areLoginOptionsNeeded()
}
......@@ -64,10 +73,14 @@ class LoginFragment : Fragment(), LoginView {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_FOR_CAS) {
if (resultCode == Activity.RESULT_OK) {
if (resultCode == Activity.RESULT_OK) {
if (requestCode == REQUEST_CODE_FOR_CAS) {
data?.apply {
presenter.authenticateWithCas(getStringExtra("cas_token"))
presenter.authenticateWithCas(getStringExtra(INTENT_CAS_TOKEN))
}
} else if (requestCode == REQUEST_CODE_FOR_OAUTH) {
data?.apply {
presenter.authenticateWithOauth(getStringExtra(INTENT_OAUTH_CREDENTIAL_TOKEN), getStringExtra(INTENT_OAUTH_CREDENTIAL_SECRET))
}
}
}
......@@ -121,7 +134,7 @@ class LoginFragment : Fragment(), LoginView {
override fun setupLoginButtonListener() {
button_log_in.setOnClickListener {
presenter.authenticate(text_username_or_email.textContent, text_password.textContent)
presenter.authenticateWithUserAndPassword(text_username_or_email.textContent, text_password.textContent)
}
}
......@@ -147,7 +160,7 @@ class LoginFragment : Fragment(), LoginView {
override fun setupCasButtonListener(casUrl: String, casToken: String) {
button_cas.setOnClickListener {
startActivityForResult(context?.webViewIntent(casUrl, casToken), REQUEST_CODE_FOR_CAS)
startActivityForResult(context?.casWebViewIntent(casUrl, casToken), REQUEST_CODE_FOR_CAS)
activity?.overridePendingTransition(R.anim.slide_up, R.anim.hold)
}
}
......@@ -173,14 +186,15 @@ class LoginFragment : Fragment(), LoginView {
text_new_to_rocket_chat.setVisible(false)
}
override fun showOauthView() {
override fun enableOauthView() {
isOauthViewEnable = true
showThreeSocialAccountsMethods()
social_accounts_container.setVisible(true)
}
override fun hideOauthView() {
override fun disableOauthView() {
isOauthViewEnable = false
social_accounts_container.setVisible(false)
button_fab.setVisible(false)
}
override fun showLoginButton() {
......@@ -192,31 +206,60 @@ class LoginFragment : Fragment(), LoginView {
}
override fun enableLoginByFacebook() {
button_facebook.isEnabled = true
button_facebook.isClickable = true
}
override fun enableLoginByGithub() {
button_github.isEnabled = true
button_github.isClickable = true
}
override fun setupGithubButtonListener(githubUrl: String, state: String) {
button_github.setOnClickListener {
startActivityForResult(context?.oauthWebViewIntent(githubUrl, state), REQUEST_CODE_FOR_OAUTH)
activity?.overridePendingTransition(R.anim.slide_up, R.anim.hold)
}
}
override fun enableLoginByGoogle() {
button_google.isEnabled = true
button_google.isClickable = true
}
// TODO: Use custom tabs instead of web view. See https://github.com/RocketChat/Rocket.Chat.Android/issues/968
override fun setupGoogleButtonListener(googleUrl: String, state: String) {
button_google.setOnClickListener {
startActivityForResult(context?.oauthWebViewIntent(googleUrl, state), REQUEST_CODE_FOR_OAUTH)
activity?.overridePendingTransition(R.anim.slide_up, R.anim.hold)
}
}
override fun enableLoginByLinkedin() {
button_linkedin.isEnabled = true
button_linkedin.isClickable = true
}
override fun setupLinkedinButtonListener(linkedinUrl: String, state: String) {
button_linkedin.setOnClickListener {
startActivityForResult(context?.oauthWebViewIntent(linkedinUrl, state), REQUEST_CODE_FOR_OAUTH)
activity?.overridePendingTransition(R.anim.slide_up, R.anim.hold)
}
}
override fun enableLoginByMeteor() {
button_meteor.isEnabled = true
button_meteor.isClickable = true
}
override fun enableLoginByTwitter() {
button_twitter.isEnabled = true
button_twitter.isClickable = true
}
override fun enableLoginByGitlab() {
button_gitlab.isEnabled = true
button_gitlab.isClickable = true
}
override fun setupGitlabButtonListener(gitlabUrl: String, state: String) {
button_gitlab.setOnClickListener {
startActivityForResult(context?.oauthWebViewIntent(gitlabUrl, state), REQUEST_CODE_FOR_OAUTH)
activity?.overridePendingTransition(R.anim.slide_up, R.anim.hold)
}
}
override fun setupFabListener() {
......@@ -249,12 +292,37 @@ class LoginFragment : Fragment(), LoginView {
text_password.requestFocus()
}
override fun alertRequiresUsername() {
showToast(getString(R.string.msg_requires_username), Toast.LENGTH_LONG)
}
override fun alertNotRecommendedVersion() {
context?.let {
AlertDialog.Builder(it)
.setMessage(getString(R.string.msg_ver_not_recommended, BuildConfig.RECOMMENDED_SERVER_VERSION))
.setPositiveButton(R.string.msg_ok, null)
.create()
.show()
}
}
override fun blockAndAlertNotRequiredVersion() {
context?.let {
AlertDialog.Builder(it)
.setMessage(getString(R.string.msg_ver_not_minimum, BuildConfig.REQUIRED_SERVER_VERSION))
.setOnDismissListener { activity?.onBackPressed() }
.setPositiveButton(R.string.msg_ok, null)
.create()
.show()
}
}
private fun showRemainingSocialAccountsView() {
social_accounts_container.postDelayed({
(0..social_accounts_container.childCount)
.mapNotNull { social_accounts_container.getChildAt(it) as? ImageButton }
.filter { it.isEnabled }
.forEach { it.visibility = View.VISIBLE }
.filter { it.isClickable }
.forEach { it.setVisible(true)}
}, 1000)
}
......@@ -284,13 +352,23 @@ class LoginFragment : Fragment(), LoginView {
}
private fun showThreeSocialAccountsMethods() {
var count = 0
for (i in 0..social_accounts_container.childCount) {
val view = social_accounts_container.getChildAt(i) as? ImageButton ?: continue
if (view.isEnabled && count < 3) {
view.visibility = View.VISIBLE
count++
}
(0..social_accounts_container.childCount)
.mapNotNull { social_accounts_container.getChildAt(it) as? ImageButton }
.filter { it.isClickable }
.take(3)
.forEach { it.setVisible(true) }
}
fun showOauthView() {
if (isOauthViewEnable) {
social_accounts_container.setVisible(true)
}
}
fun hideOauthView() {
if (isOauthViewEnable) {
social_accounts_container.setVisible(false)
button_fab.setVisible(false)
}
}
}
\ No newline at end of file
package chat.rocket.android.authentication.presentation
import android.content.Context
import android.content.Intent
import chat.rocket.android.R
import chat.rocket.android.authentication.login.ui.LoginFragment
import chat.rocket.android.authentication.signup.ui.SignupFragment
import chat.rocket.android.authentication.twofactor.ui.TwoFAFragment
import chat.rocket.android.authentication.ui.AuthenticationActivity
import chat.rocket.android.authentication.ui.newServerIntent
import chat.rocket.android.main.ui.MainActivity
import chat.rocket.android.server.ui.changeServerIntent
import chat.rocket.android.util.extensions.addFragmentBackStack
import chat.rocket.android.webview.ui.webViewIntent
class AuthenticationNavigator(internal val activity: AuthenticationActivity, internal val context: Context) {
class AuthenticationNavigator(internal val activity: AuthenticationActivity) {
fun toLogin() {
activity.addFragmentBackStack("LoginFragment", R.id.fragment_container) {
......@@ -32,7 +33,7 @@ class AuthenticationNavigator(internal val activity: AuthenticationActivity, int
}
fun toWebPage(url: String) {
activity.startActivity(context.webViewIntent(url))
activity.startActivity(activity.webViewIntent(url))
activity.overridePendingTransition(R.anim.slide_up, R.anim.hold)
}
......@@ -41,7 +42,13 @@ class AuthenticationNavigator(internal val activity: AuthenticationActivity, int
activity.finish()
}
fun toChatList(serverUrl: String) {
activity.startActivity(activity.changeServerIntent(serverUrl))
activity.finish()
}
fun toServerScreen() {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
activity.startActivity(activity.newServerIntent())
activity.finish()
}
}
\ No newline at end of file
package chat.rocket.android.authentication.presentation
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetAccountInteractor
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.MultiServerTokenRepository
import chat.rocket.android.server.domain.SettingsRepository
import chat.rocket.common.model.Token
import chat.rocket.core.TokenRepository
import chat.rocket.android.server.domain.TokenRepository
import javax.inject.Inject
class AuthenticationPresenter @Inject constructor(private val navigator: AuthenticationNavigator,
private val getCurrentServerInteractor: GetCurrentServerInteractor,
private val multiServerRepository: MultiServerTokenRepository,
private val settingsRepository: SettingsRepository,
private val tokenRepository: TokenRepository) {
fun loadCredentials(callback: (authenticated: Boolean) -> Unit) {
class AuthenticationPresenter @Inject constructor(
private val navigator: AuthenticationNavigator,
private val getCurrentServerInteractor: GetCurrentServerInteractor,
private val getAccountInteractor: GetAccountInteractor,
private val settingsRepository: SettingsRepository,
private val localRepository: LocalRepository,
private val tokenRepository: TokenRepository
) {
suspend fun loadCredentials(newServer: Boolean, callback: (authenticated: Boolean) -> Unit) {
val currentServer = getCurrentServerInteractor.get()
val serverToken = currentServer?.let { multiServerRepository.get(currentServer) }
val serverToken = currentServer?.let { tokenRepository.get(currentServer) }
val settings = currentServer?.let { settingsRepository.get(currentServer) }
val account = currentServer?.let { getAccountInteractor.get(currentServer) }
account?.let {
localRepository.save(LocalRepository.CURRENT_USERNAME_KEY, account.userName)
}
if (currentServer == null || serverToken == null || settings == null) {
if (newServer || currentServer == null || serverToken == null || settings == null) {
callback(false)
} else {
tokenRepository.save(Token(serverToken.userId, serverToken.authToken))
callback(true)
navigator.toChatList()
}
......
......@@ -4,6 +4,7 @@ import chat.rocket.android.authentication.presentation.AuthenticationNavigator
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.helper.NetworkHelper
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.server.domain.GetAccountsInteractor
import chat.rocket.android.server.domain.RefreshSettingsInteractor
import chat.rocket.android.server.domain.SaveCurrentServerInteractor
import chat.rocket.android.util.extensions.launchUI
......@@ -14,12 +15,20 @@ class ServerPresenter @Inject constructor(private val view: ServerView,
private val strategy: CancelStrategy,
private val navigator: AuthenticationNavigator,
private val serverInteractor: SaveCurrentServerInteractor,
private val refreshSettingsInteractor: RefreshSettingsInteractor) {
private val refreshSettingsInteractor: RefreshSettingsInteractor,
private val getAccountsInteractor: GetAccountsInteractor) {
fun connect(server: String) {
if (!UrlHelper.isValidUrl(server)) {
view.showInvalidServerUrlMessage()
} else {
launchUI(strategy) {
// Check if we already have an account for this server...
val account = getAccountsInteractor.get().firstOrNull { it.serverUrl == server }
if (account != null) {
navigator.toChatList(server)
return@launchUI
}
if (NetworkHelper.hasInternetAccess()) {
view.showLoading()
try {
......
package chat.rocket.android.authentication.server.presentation
interface VersionCheckView {
/**
* Alerts the user about the server version not meeting the recommended server version.
*/
fun alertNotRecommendedVersion()
/**
* Block user to proceed and alert him due to server having an unsupported server version.
*/
fun blockAndAlertNotRequiredVersion()
}
\ No newline at end of file
......@@ -56,13 +56,21 @@ class ServerFragment : Fragment(), ServerView {
enableUserInput(true)
}
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int){
showToast(resId)
}
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
override fun showGenericErrorMessage() {
showMessage(getString(R.string.msg_generic_error))
}
override fun showNoInternetConnection() = showMessage(getString(R.string.msg_no_internet_connection))
override fun showNoInternetConnection() {
showMessage(getString(R.string.msg_no_internet_connection))
}
private fun enableUserInput(value: Boolean) {
button_connect.isEnabled = value
......
......@@ -5,9 +5,12 @@ import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.helper.NetworkHelper
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.main.viewmodel.NavHeaderViewModel
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.android.util.extensions.registerPushToken
import chat.rocket.common.RocketChatException
import chat.rocket.common.util.ifNull
import chat.rocket.core.RocketChatClient
......@@ -15,6 +18,7 @@ import chat.rocket.core.internal.rest.login
import chat.rocket.core.internal.rest.me
import chat.rocket.core.internal.rest.registerPushToken
import chat.rocket.core.internal.rest.signup
import chat.rocket.core.model.Myself
import javax.inject.Inject
class SignupPresenter @Inject constructor(private val view: SignupView,
......@@ -22,8 +26,13 @@ class SignupPresenter @Inject constructor(private val view: SignupView,
private val navigator: AuthenticationNavigator,
private val localRepository: LocalRepository,
private val serverInteractor: GetCurrentServerInteractor,
private val factory: RocketChatClientFactory) {
private val client: RocketChatClient = factory.create(serverInteractor.get()!!)
private val factory: RocketChatClientFactory,
private val saveAccountInteractor: SaveAccountInteractor,
private val getAccountsInteractor: GetAccountsInteractor,
settingsInteractor: GetSettingsInteractor) {
private val currentServer = serverInteractor.get()!!
private val client: RocketChatClient = factory.create(currentServer)
private var settings: PublicSettings = settingsInteractor.get(serverInteractor.get()!!)
fun signup(name: String, username: String, password: String, email: String) {
val server = serverInteractor.get()
......@@ -55,7 +64,8 @@ class SignupPresenter @Inject constructor(private val view: SignupView,
// TODO This function returns a user token so should we save it?
client.login(username, password)
val me = client.me()
localRepository.save(LocalRepository.USERNAME_KEY, me.username)
localRepository.save(LocalRepository.CURRENT_USERNAME_KEY, me.username)
saveAccount(me)
registerPushToken()
navigator.toChatList()
} catch (exception: RocketChatException) {
......@@ -90,8 +100,21 @@ class SignupPresenter @Inject constructor(private val view: SignupView,
private suspend fun registerPushToken() {
localRepository.get(LocalRepository.KEY_PUSH_TOKEN)?.let {
client.registerPushToken(it)
client.registerPushToken(it, getAccountsInteractor.get(), factory)
}
// TODO: Schedule push token registering when it comes up null
// TODO: When the push token is null, at some point we should receive it with
// onTokenRefresh() on FirebaseTokenService, we need to confirm it.
}
private suspend fun saveAccount(me: Myself) {
val icon = settings.favicon()?.let {
UrlHelper.getServerLogoUrl(currentServer, it)
}
val logo = settings.wideTile()?.let {
UrlHelper.getServerLogoUrl(currentServer, it)
}
val thumb = UrlHelper.getAvatarUrl(currentServer, me.username!!)
val account = Account(currentServer, icon, logo, me.username!!, thumb)
saveAccountInteractor.save(account)
}
}
\ No newline at end of file
......@@ -97,9 +97,13 @@ class SignupFragment : Fragment(), SignupView {
enableUserInput(true)
}
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int) {
showToast(resId)
}
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showGenericErrorMessage() {
showMessage(getString(R.string.msg_generic_error))
......
package chat.rocket.android.authentication.twofactor.presentation
import chat.rocket.android.authentication.domain.model.TokenModel
import chat.rocket.android.authentication.presentation.AuthenticationNavigator
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.helper.NetworkHelper
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.MultiServerTokenRepository
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.android.util.extensions.registerPushToken
import chat.rocket.common.RocketChatAuthException
import chat.rocket.common.RocketChatException
import chat.rocket.common.util.ifNull
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.login
import chat.rocket.core.internal.rest.registerPushToken
import chat.rocket.core.internal.rest.me
import chat.rocket.core.model.Myself
import javax.inject.Inject
class TwoFAPresenter @Inject constructor(private val view: TwoFAView,
private val strategy: CancelStrategy,
private val navigator: AuthenticationNavigator,
private val multiServerRepository: MultiServerTokenRepository,
private val tokenRepository: TokenRepository,
private val localRepository: LocalRepository,
private val serverInteractor: GetCurrentServerInteractor,
private val factory: RocketChatClientFactory) {
private val client: RocketChatClient = factory.create(serverInteractor.get()!!)
private val factory: RocketChatClientFactory,
private val saveAccountInteractor: SaveAccountInteractor,
private val getAccountsInteractor: GetAccountsInteractor,
settingsInteractor: GetSettingsInteractor) {
private val currentServer = serverInteractor.get()!!
private val client: RocketChatClient = factory.create(currentServer)
private var settings: PublicSettings = settingsInteractor.get(serverInteractor.get()!!)
// TODO: If the usernameOrEmail and password was informed by the user on the previous screen, then we should pass only the pin, like this: fun authenticate(pin: EditText)
fun authenticate(usernameOrEmail: String, password: String, twoFactorAuthenticationCode: String) {
......@@ -45,10 +52,9 @@ class TwoFAPresenter @Inject constructor(private val view: TwoFAView,
// The token is saved via the client TokenProvider
val token =
client.login(usernameOrEmail, password, twoFactorAuthenticationCode)
multiServerRepository.save(
server,
TokenModel(token.userId, token.authToken)
)
val me = client.me()
saveAccount(me)
tokenRepository.save(server, token)
registerPushToken()
navigator.toChatList()
} catch (exception: RocketChatException) {
......@@ -76,8 +82,21 @@ class TwoFAPresenter @Inject constructor(private val view: TwoFAView,
private suspend fun registerPushToken() {
localRepository.get(LocalRepository.KEY_PUSH_TOKEN)?.let {
client.registerPushToken(it)
client.registerPushToken(it, getAccountsInteractor.get(), factory)
}
// TODO: Schedule push token registering when it comes up null
// TODO: When the push token is null, at some point we should receive it with
// onTokenRefresh() on FirebaseTokenService, we need to confirm it.
}
private suspend fun saveAccount(me: Myself) {
val icon = settings.favicon()?.let {
UrlHelper.getServerLogoUrl(currentServer, it)
}
val logo = settings.wideTile()?.let {
UrlHelper.getServerLogoUrl(currentServer, it)
}
val thumb = UrlHelper.getAvatarUrl(currentServer, me.username!!)
val account = Account(currentServer, icon, logo, me.username!!, thumb)
saveAccountInteractor.save(account)
}
}
\ No newline at end of file
......@@ -66,7 +66,9 @@ class TwoFAFragment : Fragment(), TwoFAView {
text_two_factor_auth.shake()
}
override fun alertInvalidTwoFactorAuthenticationCode() = showMessage(getString(R.string.msg_invalid_2fa_code))
override fun alertInvalidTwoFactorAuthenticationCode() {
showMessage(getString(R.string.msg_invalid_2fa_code))
}
override fun showLoading() {
enableUserInput(false)
......@@ -78,9 +80,13 @@ class TwoFAFragment : Fragment(), TwoFAView {
enableUserInput(true)
}
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int) {
showToast(resId)
}
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
......
package chat.rocket.android.authentication.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v7.app.AppCompatActivity
......@@ -7,40 +9,58 @@ import chat.rocket.android.R
import chat.rocket.android.authentication.presentation.AuthenticationPresenter
import chat.rocket.android.authentication.server.ui.ServerFragment
import chat.rocket.android.util.extensions.addFragment
import chat.rocket.android.util.extensions.launchUI
import dagger.android.AndroidInjection
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.HasSupportFragmentInjector
import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
import javax.inject.Inject
class AuthenticationActivity : AppCompatActivity(), HasSupportFragmentInjector {
@Inject lateinit var fragmentDispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
@Inject lateinit var presenter: AuthenticationPresenter
val job = Job()
override fun onCreate(savedInstanceState: Bundle?) {
AndroidInjection.inject(this)
presenter.loadCredentials { authenticated ->
if (authenticated) {
// just call onCreate, and the presenter will call the navigator...
super.onCreate(savedInstanceState)
} else {
showServerInput(savedInstanceState)
setContentView(R.layout.activity_authentication)
setTheme(R.style.AuthenticationTheme)
super.onCreate(savedInstanceState)
launch(UI + job) {
val newServer = intent.getBooleanExtra(INTENT_ADD_NEW_SERVER, false)
presenter.loadCredentials(newServer) { authenticated ->
if (!authenticated) {
showServerInput(savedInstanceState)
}
}
}
}
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
override fun supportFragmentInjector(): AndroidInjector<Fragment> {
return fragmentDispatchingAndroidInjector
}
fun showServerInput(savedInstanceState: Bundle?) {
setContentView(R.layout.activity_authentication)
setTheme(R.style.AuthenticationTheme)
super.onCreate(savedInstanceState)
addFragment("ServerFragment", R.id.fragment_container) {
ServerFragment.newInstance()
}
}
}
const val INTENT_ADD_NEW_SERVER = "INTENT_ADD_NEW_SERVER"
fun Context.newServerIntent(): Intent {
return Intent(this, AuthenticationActivity::class.java).apply {
putExtra(INTENT_ADD_NEW_SERVER, true)
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
\ No newline at end of file
......@@ -2,9 +2,9 @@ package chat.rocket.android.chatroom.adapter
import android.support.annotation.IntDef
const val PEOPLE = 0L
const val ROOMS = 1L
const val PEOPLE = 0
const val ROOMS = 1
@Retention(AnnotationRetention.SOURCE)
@IntDef(value = [PEOPLE, ROOMS])
@IntDef(PEOPLE, ROOMS)
annotation class AutoCompleteType
......@@ -49,6 +49,10 @@ class ChatRoomAdapter(
val view = parent.inflate(R.layout.message_url_preview)
UrlPreviewViewHolder(view, actionsListener, reactionListener)
}
BaseViewModel.ViewType.MESSAGE_ATTACHMENT -> {
val view = parent.inflate(R.layout.item_message_attachment)
MessageAttachmentViewHolder(view, actionsListener, reactionListener)
}
else -> {
throw InvalidParameterException("TODO - implement for ${viewType.toViewType()}")
}
......@@ -87,6 +91,7 @@ class ChatRoomAdapter(
is AudioAttachmentViewHolder -> holder.bind(dataSet[position] as AudioAttachmentViewModel)
is VideoAttachmentViewHolder -> holder.bind(dataSet[position] as VideoAttachmentViewModel)
is UrlPreviewViewHolder -> holder.bind(dataSet[position] as UrlPreviewViewModel)
is MessageAttachmentViewHolder -> holder.bind(dataSet[position] as MessageAttachmentViewModel)
}
}
......@@ -117,19 +122,22 @@ class ChatRoomAdapter(
fun updateItem(message: BaseViewModel<*>) {
var index = dataSet.indexOfLast { it.messageId == message.messageId }
val indexOfFirst = dataSet.indexOfFirst { it.messageId == message.messageId }
val indexOfNext = dataSet.indexOfFirst { it.messageId == message.messageId }
Timber.d("index: $index")
if (index > -1) {
dataSet[index] = message
notifyItemChanged(index)
while (dataSet[index].nextDownStreamMessage != null) {
dataSet[index].nextDownStreamMessage!!.reactions = message.reactions
notifyItemChanged(--index)
dataSet.forEachIndexed { index, viewModel ->
if (viewModel.messageId == message.messageId) {
if (viewModel.nextDownStreamMessage == null) {
viewModel.reactions = message.reactions
}
notifyItemChanged(index)
}
}
// Delete message only if current is a system message update, i.e.: Message Removed
if (message.message.isSystemMessage() && indexOfFirst > -1 && indexOfFirst != index) {
dataSet.removeAt(indexOfFirst)
notifyItemRemoved(indexOfFirst)
if (message.message.isSystemMessage() && indexOfNext > -1 && indexOfNext != index) {
dataSet.removeAt(indexOfNext)
notifyItemRemoved(indexOfNext)
}
}
}
......
......@@ -12,7 +12,7 @@ import chat.rocket.android.widget.autocompletion.ui.BaseSuggestionViewHolder
import chat.rocket.android.widget.autocompletion.ui.SuggestionsAdapter
class CommandSuggestionsAdapter : SuggestionsAdapter<CommandSuggestionsViewHolder>(token = "/",
constraint = CONSTRAINT_BOUND_TO_START, threshold = UNLIMITED_RESULT_COUNT) {
constraint = CONSTRAINT_BOUND_TO_START, threshold = RESULT_COUNT_UNLIMITED) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommandSuggestionsViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.suggestion_command_item, parent,
......
package chat.rocket.android.chatroom.adapter
import android.text.method.LinkMovementMethod
import android.view.View
import chat.rocket.android.chatroom.viewmodel.MessageAttachmentViewModel
import chat.rocket.android.widget.emoji.EmojiReactionListener
import kotlinx.android.synthetic.main.item_message.view.*
class MessageAttachmentViewHolder(
itemView: View,
listener: ActionsListener,
reactionListener: EmojiReactionListener? = null
) : BaseViewHolder<MessageAttachmentViewModel>(itemView, listener, reactionListener) {
init {
with(itemView) {
text_content.movementMethod = LinkMovementMethod()
setupActionMenu(text_content)
}
}
override fun bindViews(data: MessageAttachmentViewModel) {
with(itemView) {
text_message_time.text = data.time
text_sender.text = data.senderName
text_content.text = data.content
}
}
}
\ No newline at end of file
......@@ -97,7 +97,7 @@ class MessageReactionsAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>()
val countTextView = findViewById<TextView>(R.id.text_count)
emojiTextView.text = reaction.unicode
countTextView.text = reaction.count.toString()
val myself = localRepository.get(LocalRepository.USERNAME_KEY)
val myself = localRepository.get(LocalRepository.CURRENT_USERNAME_KEY)
if (reaction.usernames.contains(myself)) {
val context = itemView.context
val resources = context.resources
......
package chat.rocket.android.chatroom.adapter
import DrawableHelper
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
......@@ -13,10 +14,32 @@ import chat.rocket.android.util.extensions.setVisible
import chat.rocket.android.widget.autocompletion.model.SuggestionModel
import chat.rocket.android.widget.autocompletion.ui.BaseSuggestionViewHolder
import chat.rocket.android.widget.autocompletion.ui.SuggestionsAdapter
import chat.rocket.common.model.UserStatus
import com.facebook.drawee.view.SimpleDraweeView
class PeopleSuggestionsAdapter : SuggestionsAdapter<PeopleSuggestionViewHolder>("@") {
class PeopleSuggestionsAdapter(context: Context) : SuggestionsAdapter<PeopleSuggestionViewHolder>("@") {
init {
val allDescription = context.getString(R.string.suggest_all_description)
val hereDescription = context.getString(R.string.suggest_here_description)
val pinnedList = listOf(
PeopleSuggestionViewModel(imageUri = null,
text = "all",
username = "all",
name = allDescription,
status = null,
pinned = false,
searchList = listOf("all")),
PeopleSuggestionViewModel(imageUri = null,
text = "here",
username = "here",
name = hereDescription,
status = null,
pinned = false,
searchList = listOf("here"))
)
setPinnedSuggestions(pinnedList)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PeopleSuggestionViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.suggestion_member_item, parent,
false)
......@@ -34,15 +57,19 @@ class PeopleSuggestionsAdapter : SuggestionsAdapter<PeopleSuggestionViewHolder>(
val statusView = itemView.findViewById<ImageView>(R.id.image_status)
username.text = item.username
name.text = item.name
if (item.imageUri.isEmpty()) {
if (item.imageUri?.isEmpty() != false) {
avatar.setVisible(false)
} else {
avatar.setVisible(true)
avatar.setImageURI(item.imageUri)
}
val status = item.status ?: UserStatus.Offline()
val statusDrawable = DrawableHelper.getUserStatusDrawable(status, itemView.context)
statusView.setImageDrawable(statusDrawable)
val status = item.status
if (status != null) {
val statusDrawable = DrawableHelper.getUserStatusDrawable(status, itemView.context)
statusView.setImageDrawable(statusDrawable)
} else {
statusView.setVisible(false)
}
setOnClickListener {
itemClickListener?.onClick(item)
}
......
......@@ -53,7 +53,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
private val currentServer = serverInteractor.get()!!
private val manager = factory.create(currentServer)
private val client = manager.client
private var settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)!!
private var settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)
private val messagesChannel = Channel<Message>()
private var chatRoomId: String? = null
......@@ -291,7 +291,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
view.showReplyingAction(
username = user,
replyMarkdown = "[ ]($serverUrl/$room/$roomName?msg=$id) $mention ",
quotedMessage = m.message
quotedMessage = mapper.map(message).last().preview?.message ?: ""
)
}
}
......@@ -364,7 +364,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
try {
val members = client.getMembers(chatRoomId, roomTypeOf(chatRoomType), offset, 50).result
usersRepository.saveAll(members)
val self = localRepository.get(LocalRepository.USERNAME_KEY)
val self = localRepository.get(LocalRepository.CURRENT_USERNAME_KEY)
// Take at most the 100 most recent messages distinguished by user. Can return less.
val recentMessages = messagesRepository.getRecentMessages(chatRoomId, 100)
.filterNot { filterSelfOut && it.sender?.username == self }
......@@ -402,7 +402,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
}
}
fun spotlight(query: String, @AutoCompleteType type: Long, filterSelfOut: Boolean = false) {
fun spotlight(query: String, @AutoCompleteType type: Int, filterSelfOut: Boolean = false) {
launchUI(strategy) {
try {
val (users, rooms) = client.spotlight(query)
......@@ -411,7 +411,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
if (users.isNotEmpty()) {
usersRepository.saveAll(users)
}
val self = localRepository.get(LocalRepository.USERNAME_KEY)
val self = localRepository.get(LocalRepository.CURRENT_USERNAME_KEY)
view.populatePeopleSuggestions(users.map {
val username = it.username ?: ""
val name = it.name ?: ""
......@@ -499,7 +499,6 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
//TODO: cache the commands
val commands = client.commands(0, 100).result
view.populateCommandSuggestions(commands.map {
println("${it.command} - ${it.description}")
CommandSuggestionViewModel(it.command, it.description ?: "", listOf(it.command))
})
} catch (ex: RocketChatException) {
......
......@@ -24,7 +24,7 @@ class PinnedMessagesPresenter @Inject constructor(private val view: PinnedMessag
getSettingsInteractor: GetSettingsInteractor) {
private val client = factory.create(serverInteractor.get()!!)
private var settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)!!
private var settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)
private var pinnedMessagesListOffset: Int = 0
/**
......
package chat.rocket.android.chatroom.ui
import android.graphics.drawable.Drawable
import android.support.design.widget.BaseTransientBottomBar
import android.support.v4.view.ViewCompat
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
......@@ -27,7 +27,6 @@ class ActionSnackbar : BaseTransientBottomBar<ActionSnackbar> {
actionSnackbar.cancelView = view.findViewById(R.id.image_view_action_cancel_quote) as ImageView
actionSnackbar.duration = BaseTransientBottomBar.LENGTH_INDEFINITE
val spannable = Markwon.markdown(context, content).trim()
actionSnackbar.marginDrawable = context.getDrawable(R.drawable.quote)
actionSnackbar.messageTextView.content = spannable
return actionSnackbar
}
......@@ -37,19 +36,16 @@ class ActionSnackbar : BaseTransientBottomBar<ActionSnackbar> {
lateinit var cancelView: View
private lateinit var messageTextView: TextView
private lateinit var titleTextView: TextView
private lateinit var marginDrawable: Drawable
var text: String = ""
set(value) {
val spannable = parser.renderMarkdown(value) as Spannable
spannable.setSpan(MessageParser.QuoteMarginSpan(marginDrawable, 10), 0, spannable.length, 0)
val spannable = SpannableStringBuilder.valueOf(value)
messageTextView.content = spannable
}
var title: String = ""
set(value) {
val spannable = Markwon.markdown(this.context, value) as Spannable
spannable.setSpan(MessageParser.QuoteMarginSpan(marginDrawable, 10), 0, spannable.length, 0)
titleTextView.content = spannable
}
......@@ -68,17 +64,17 @@ class ActionSnackbar : BaseTransientBottomBar<ActionSnackbar> {
override fun animateContentOut(delay: Int, duration: Int) {
ViewCompat.setScaleY(content, 1f)
ViewCompat.animate(content)
.scaleY(0f)
.setDuration(duration.toLong())
.startDelay = delay.toLong()
.scaleY(0f)
.setDuration(duration.toLong())
.startDelay = delay.toLong()
}
override fun animateContentIn(delay: Int, duration: Int) {
ViewCompat.setScaleY(content, 0f)
ViewCompat.animate(content)
.scaleY(1f)
.setDuration(duration.toLong())
.startDelay = delay.toLong()
.scaleY(1f)
.setDuration(duration.toLong())
.startDelay = delay.toLong()
}
}
}
\ No newline at end of file
......@@ -102,31 +102,39 @@ class ChatRoomActivity : AppCompatActivity(), HasSupportFragmentInjector {
return fragmentDispatchingAndroidInjector
}
fun showRoomTypeIcon(showRoomTypeIcon: Boolean) {
if (showRoomTypeIcon) {
val roomType = roomTypeOf(chatRoomType)
val drawable = when (roomType) {
is RoomType.Channel -> {
DrawableHelper.getDrawableFromId(R.drawable.ic_room_channel, this)
}
is RoomType.PrivateGroup -> {
DrawableHelper.getDrawableFromId(R.drawable.ic_room_lock, this)
}
is RoomType.DirectMessage -> {
DrawableHelper.getDrawableFromId(R.drawable.ic_room_dm, this)
}
else -> null
}
drawable?.let {
val wrappedDrawable = DrawableHelper.wrapDrawable(it)
val mutableDrawable = wrappedDrawable.mutate()
DrawableHelper.tintDrawable(mutableDrawable, this, R.color.white)
DrawableHelper.compoundDrawable(text_room_name, mutableDrawable)
}
} else {
text_room_name.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
}
private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayShowTitleEnabled(false)
text_room_name.textContent = chatRoomName
val roomType = roomTypeOf(chatRoomType)
val drawable = when (roomType) {
is RoomType.Channel -> {
DrawableHelper.getDrawableFromId(R.drawable.ic_room_channel, this)
}
is RoomType.PrivateGroup -> {
DrawableHelper.getDrawableFromId(R.drawable.ic_room_lock, this)
}
is RoomType.DirectMessage -> {
DrawableHelper.getDrawableFromId(R.drawable.ic_room_dm, this)
}
else -> null
}
drawable?.let {
val wrappedDrawable = DrawableHelper.wrapDrawable(it)
val mutableDrawable = wrappedDrawable.mutate()
DrawableHelper.tintDrawable(mutableDrawable, this, R.color.white)
DrawableHelper.compoundDrawable(text_room_name, mutableDrawable)
}
showRoomTypeIcon(true)
toolbar.setNavigationOnClickListener {
finishActivity()
......
......@@ -37,7 +37,9 @@ import kotlinx.android.synthetic.main.message_attachment_options.*
import kotlinx.android.synthetic.main.message_composer.*
import kotlinx.android.synthetic.main.message_list.*
import timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import kotlin.math.absoluteValue
fun newInstance(chatRoomId: String,
chatRoomName: String,
......@@ -89,6 +91,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
private val centerX by lazy { recycler_view.right }
private val centerY by lazy { recycler_view.bottom }
private val handler = Handler()
private var verticalScrollOffset = AtomicInteger(0)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
......@@ -121,6 +124,10 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
setupMessageComposer()
setupSuggestionsView()
setupActionSnackbar()
activity?.apply {
(this as? ChatRoomActivity)?.showRoomTypeIcon(true)
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
......@@ -209,13 +216,54 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
}
})
}
recycler_view.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
val y = oldBottom - bottom
if (y.absoluteValue > 0) {
// if y is positive the keyboard is up else it's down
recycler_view.post {
if (y > 0 || verticalScrollOffset.get().absoluteValue >= y.absoluteValue) {
recycler_view.scrollBy(0, y)
} else {
recycler_view.scrollBy(0, verticalScrollOffset.get())
}
}
}
}
recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() {
var state = AtomicInteger(RecyclerView.SCROLL_STATE_IDLE)
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
state.compareAndSet(RecyclerView.SCROLL_STATE_IDLE, newState)
when (newState) {
RecyclerView.SCROLL_STATE_IDLE -> {
if (!state.compareAndSet(RecyclerView.SCROLL_STATE_SETTLING, newState)) {
state.compareAndSet(RecyclerView.SCROLL_STATE_DRAGGING, newState)
}
}
RecyclerView.SCROLL_STATE_DRAGGING -> {
state.compareAndSet(RecyclerView.SCROLL_STATE_IDLE, newState)
}
RecyclerView.SCROLL_STATE_SETTLING -> {
state.compareAndSet(RecyclerView.SCROLL_STATE_DRAGGING, newState)
}
}
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (state.get() != RecyclerView.SCROLL_STATE_IDLE) {
verticalScrollOffset.getAndAdd(dy)
}
}
})
}
val oldMessagesCount = adapter.itemCount
adapter.appendData(dataSet)
recycler_view.scrollToPosition(92)
if (oldMessagesCount == 0 && dataSet.isNotEmpty()) {
recycler_view.scrollToPosition(0)
verticalScrollOffset.set(0)
}
presenter.loadActiveMembers(chatRoomId, chatRoomType, filterSelfOut = true)
}
......@@ -236,11 +284,14 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
presenter.uploadFile(chatRoomId, uri, "")
}
override fun showInvalidFileMessage() = showMessage(getString(R.string.msg_invalid_file))
override fun showInvalidFileMessage() {
showMessage(getString(R.string.msg_invalid_file))
}
override fun showNewMessage(message: List<BaseViewModel<*>>) {
adapter.prependData(message)
recycler_view.scrollToPosition(0)
verticalScrollOffset.set(0)
}
override fun disableSendMessageButton() {
......@@ -281,6 +332,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
if (!recycler_view.isAtBottom()) {
if (adapter.itemCount > 0) {
recycler_view.scrollToPosition(0)
verticalScrollOffset.set(0)
}
}
}
......@@ -290,9 +342,13 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
override fun hideLoading() = view_loading.setVisible(false)
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int) {
showToast(resId)
}
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
......@@ -426,6 +482,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
private fun setupFab() {
button_fab.setOnClickListener {
recycler_view.scrollToPosition(0)
verticalScrollOffset.set(0)
button_fab.hide()
}
}
......@@ -450,11 +507,6 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
emojiKeyboardPopup.listener = this
text_message.listener = object : ComposerEditText.ComposerEditTextListener {
override fun onKeyboardOpened() {
if (recycler_view.isAtBottom()) {
if (adapter.itemCount > 0) {
recycler_view.scrollToPosition(0)
}
}
}
override fun onKeyboardClosed() {
......@@ -521,7 +573,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
private fun setupSuggestionsView() {
suggestions_view.anchorTo(text_message)
.setMaximumHeight(resources.getDimensionPixelSize(R.dimen.suggestions_box_max_height))
.addTokenAdapter(PeopleSuggestionsAdapter())
.addTokenAdapter(PeopleSuggestionsAdapter(context!!))
.addTokenAdapter(CommandSuggestionsAdapter())
.addTokenAdapter(RoomSuggestionsAdapter())
.addSuggestionProviderAction("@") { query ->
......
......@@ -66,9 +66,13 @@ class PinnedMessagesFragment : Fragment(), PinnedMessagesView {
override fun hideLoading() = view_loading.setVisible(false)
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int) {
showToast(resId)
}
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
......
......@@ -12,8 +12,9 @@ data class AudioAttachmentViewModel(
override val attachmentTitle: CharSequence,
override val id: Long,
override var reactions: List<ReactionViewModel>,
override var nextDownStreamMessage: BaseViewModel<*>? = null
) : BaseFileAttachmentViewModel<AudioAttachment> {
override var nextDownStreamMessage: BaseViewModel<*>? = null,
override var preview: Message? = null
) : BaseFileAttachmentViewModel<AudioAttachment> {
override val viewType: Int
get() = BaseViewModel.ViewType.AUDIO_ATTACHMENT.viewType
override val layoutId: Int
......
......@@ -11,6 +11,7 @@ interface BaseViewModel<out T> {
val layoutId: Int
var reactions: List<ReactionViewModel>
var nextDownStreamMessage: BaseViewModel<*>?
var preview: Message?
enum class ViewType(val viewType: Int) {
MESSAGE(0),
......
......@@ -12,7 +12,8 @@ data class ImageAttachmentViewModel(
override val attachmentTitle: CharSequence,
override val id: Long,
override var reactions: List<ReactionViewModel>,
override var nextDownStreamMessage: BaseViewModel<*>? = null
override var nextDownStreamMessage: BaseViewModel<*>? = null,
override var preview: Message? = null
) : BaseFileAttachmentViewModel<ImageAttachment> {
override val viewType: Int
get() = BaseViewModel.ViewType.IMAGE_ATTACHMENT.viewType
......
package chat.rocket.android.chatroom.viewmodel
import chat.rocket.android.R
import chat.rocket.core.model.Message
data class MessageAttachmentViewModel(
override val message: Message,
override val rawData: Message,
override val messageId: String,
var senderName: String,
val time: CharSequence,
val content: CharSequence,
val isPinned: Boolean,
override var reactions: List<ReactionViewModel>,
override var nextDownStreamMessage: BaseViewModel<*>? = null,
var messageLink: String? = null,
override var preview: Message? = null
) : BaseViewModel<Message> {
override val viewType: Int
get() = BaseViewModel.ViewType.MESSAGE_ATTACHMENT.viewType
override val layoutId: Int
get() = R.layout.item_message_attachment
}
\ No newline at end of file
......@@ -14,6 +14,7 @@ data class MessageViewModel(
override val isPinned: Boolean,
override var reactions: List<ReactionViewModel>,
override var nextDownStreamMessage: BaseViewModel<*>? = null,
override var preview: Message? = null,
var isFirstUnread: Boolean
) : BaseMessageViewModel<Message> {
override val viewType: Int
......
......@@ -13,7 +13,8 @@ data class UrlPreviewViewModel(
val description: CharSequence?,
val thumbUrl: String?,
override var reactions: List<ReactionViewModel>,
override var nextDownStreamMessage: BaseViewModel<*>? = null
override var nextDownStreamMessage: BaseViewModel<*>? = null,
override var preview: Message? = null
) : BaseViewModel<Url> {
override val viewType: Int
get() = BaseViewModel.ViewType.URL_PREVIEW.viewType
......
......@@ -12,7 +12,8 @@ data class VideoAttachmentViewModel(
override val attachmentTitle: CharSequence,
override val id: Long,
override var reactions: List<ReactionViewModel>,
override var nextDownStreamMessage: BaseViewModel<*>? = null
override var nextDownStreamMessage: BaseViewModel<*>? = null,
override var preview: Message? = null
) : BaseFileAttachmentViewModel<VideoAttachment> {
override val viewType: Int
get() = BaseViewModel.ViewType.VIDEO_ATTACHMENT.viewType
......
......@@ -4,9 +4,7 @@ import DateTimeHelper
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import chat.rocket.android.R
......@@ -15,7 +13,6 @@ import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.*
import chat.rocket.android.widget.emoji.EmojiParser
import chat.rocket.core.TokenRepository
import chat.rocket.core.model.Message
import chat.rocket.core.model.MessageType
import chat.rocket.core.model.Value
......@@ -25,22 +22,23 @@ import chat.rocket.core.model.url.Url
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.withContext
import okhttp3.HttpUrl
import timber.log.Timber
import java.security.InvalidParameterException
import javax.inject.Inject
class ViewModelMapper @Inject constructor(private val context: Context,
private val parser: MessageParser,
private val messagesRepository: MessagesRepository,
private val getAccountInteractor: GetAccountInteractor,
tokenRepository: TokenRepository,
localRepository: LocalRepository,
serverInteractor: GetCurrentServerInteractor,
getSettingsInteractor: GetSettingsInteractor) {
getSettingsInteractor: GetSettingsInteractor,
localRepository: LocalRepository) {
private var settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)!!
private val currentServer = serverInteractor.get()!!
private val settings: Map<String, Value<Any>> = getSettingsInteractor.get(currentServer)
private val baseUrl = settings.baseUrl()
private val currentUsername: String? = localRepository.get(LocalRepository.USERNAME_KEY)
private val token = tokenRepository.get()
private val token = tokenRepository.get(currentServer)
private val currentUsername: String? = localRepository.get(LocalRepository.CURRENT_USERNAME_KEY)
suspend fun map(message: Message): List<BaseViewModel<*>> {
return translate(message)
......@@ -70,9 +68,17 @@ class ViewModelMapper @Inject constructor(private val context: Context,
}
mapMessage(message).let {
if (list.size > 0) {
it.preview = list[0].preview
}
list.add(it)
}
for (i in list.size - 1 downTo 0) {
val next = if (i - 1 < 0) null else list[i - 1]
list[i].nextDownStreamMessage = next
}
return@withContext list
}
......@@ -85,27 +91,47 @@ class ViewModelMapper @Inject constructor(private val context: Context,
val description = url.meta?.description
return UrlPreviewViewModel(message, url, message.id, title, hostname, description, thumb,
getReactions(message))
getReactions(message), preview = message.copy(message = url.url))
}
private fun mapAttachment(message: Message, attachment: Attachment): BaseViewModel<*>? {
private suspend fun mapAttachment(message: Message, attachment: Attachment): BaseViewModel<*>? {
return when (attachment) {
is FileAttachment -> mapFileAttachment(message, attachment)
is MessageAttachment -> mapMessageAttachment(message, attachment)
else -> null
}
}
private suspend fun mapMessageAttachment(message: Message, attachment: MessageAttachment): MessageAttachmentViewModel {
val attachmentAuthor = attachment.author!!
val time = getTime(attachment.timestamp!!)
val attachmentText = when (attachment.attachments.orEmpty().firstOrNull()) {
is ImageAttachment -> context.getString(R.string.msg_preview_photo)
is VideoAttachment -> context.getString(R.string.msg_preview_video)
is AudioAttachment -> context.getString(R.string.msg_preview_audio)
else -> attachment.text ?: ""
}
val content = stripMessageQuotes(message)
return MessageAttachmentViewModel(message = content, rawData = message,
messageId = message.id, time = time, senderName = attachmentAuthor,
content = attachmentText, isPinned = message.pinned, reactions = getReactions(message),
preview = message.copy(message = content.message))
}
private fun mapFileAttachment(message: Message, attachment: FileAttachment): BaseViewModel<*>? {
val attachmentUrl = attachmentUrl(attachment)
val attachmentTitle = attachmentTitle(attachment)
val id = attachmentId(message, attachment)
return when (attachment) {
is ImageAttachment -> ImageAttachmentViewModel(message, attachment, message.id,
attachmentUrl, attachmentTitle, id, getReactions(message))
attachmentUrl, attachmentTitle, id, getReactions(message),
preview = message.copy(message = context.getString(R.string.msg_preview_photo)))
is VideoAttachment -> VideoAttachmentViewModel(message, attachment, message.id,
attachmentUrl, attachmentTitle, id, getReactions(message))
attachmentUrl, attachmentTitle, id, getReactions(message),
preview = message.copy(message = context.getString(R.string.msg_preview_video)))
is AudioAttachment -> AudioAttachmentViewModel(message, attachment, message.id,
attachmentUrl, attachmentTitle, id, getReactions(message))
attachmentUrl, attachmentTitle, id, getReactions(message),
preview = message.copy(message = context.getString(R.string.msg_preview_audio)))
else -> null
}
}
......@@ -149,32 +175,20 @@ class ViewModelMapper @Inject constructor(private val context: Context,
val sender = getSenderName(message)
val time = getTime(message.timestamp)
val avatar = getUserAvatar(message)
val preview = mapMessagePreview(message)
val baseUrl = settings.baseUrl()
var quote: Message? = null
val urls = ArrayList<Url>()
message.urls?.let {
if (it.isEmpty()) return@let
for (url in it) {
urls.add(url)
baseUrl?.let {
val quoteUrl = HttpUrl.parse(url.url)
val serverUrl = HttpUrl.parse(baseUrl)
if (quoteUrl != null && serverUrl != null) {
quote = makeQuote(quoteUrl, serverUrl)?.let {
getMessageWithoutQuoteMarkdown(it)
}
}
}
}
}
val content = getContent(context, getMessageWithoutQuoteMarkdown(message), quote)
MessageViewModel(message = getMessageWithoutQuoteMarkdown(message), rawData = message,
val content = getContent(stripMessageQuotes(message))
MessageViewModel(message = stripMessageQuotes(message), rawData = message,
messageId = message.id, avatar = avatar!!, time = time, senderName = sender,
content = content, isPinned = message.pinned, reactions = getReactions(message),
isFirstUnread = false)
isFirstUnread = false, preview = preview)
}
private suspend fun mapMessagePreview(message: Message): Message {
return when (message.isSystemMessage()) {
false -> stripMessageQuotes(message)
true -> message.copy(message = getSystemMessage(message).toString())
}
}
private fun getReactions(message: Message): List<ReactionViewModel> {
......@@ -196,7 +210,7 @@ class ViewModelMapper @Inject constructor(private val context: Context,
return reactions ?: emptyList()
}
private fun getMessageWithoutQuoteMarkdown(message: Message): Message {
private suspend fun stripMessageQuotes(message: Message): Message {
val baseUrl = settings.baseUrl()
return message.copy(
message = message.message.replace("\\[\\s\\]\\($baseUrl.*\\)".toRegex(), "").trim()
......@@ -227,32 +241,14 @@ class ViewModelMapper @Inject constructor(private val context: Context,
private fun getTime(timestamp: Long) = DateTimeHelper.getTime(DateTimeHelper.getLocalDateTime(timestamp))
private fun makeQuote(quoteUrl: HttpUrl, serverUrl: HttpUrl): Message? {
if (quoteUrl.host() == serverUrl.host()) {
val msgIdToQuote = quoteUrl.queryParameter("msg")
Timber.d("Will quote message Id: $msgIdToQuote")
return if (msgIdToQuote != null) messagesRepository.getById(msgIdToQuote) else null
}
return null
}
private suspend fun getContent(context: Context, message: Message, quote: Message?): CharSequence {
private suspend fun getContent(message: Message): CharSequence {
return when (message.isSystemMessage()) {
true -> getSystemMessage(message, context)
false -> getNormalMessage(message, quote)
}
}
private suspend fun getNormalMessage(message: Message, quote: Message?): CharSequence {
var quoteViewModel: MessageViewModel? = null
if (quote != null) {
val quoteMessage: Message = quote
quoteViewModel = mapMessage(quoteMessage)
true -> getSystemMessage(message)
false -> parser.renderMarkdown(message, currentUsername)
}
return parser.renderMarkdown(message.message, quoteViewModel, currentUsername)
}
private fun getSystemMessage(message: Message, context: Context): CharSequence {
private fun getSystemMessage(message: Message): CharSequence {
val content = when (message.type) {
//TODO: Add implementation for Welcome type.
is MessageType.MessageRemoved -> context.getString(R.string.message_removed)
......@@ -261,68 +257,17 @@ class ViewModelMapper @Inject constructor(private val context: Context,
is MessageType.UserAdded -> context.getString(R.string.message_user_added_by, message.message, message.sender?.username)
is MessageType.RoomNameChanged -> context.getString(R.string.message_room_name_changed, message.message, message.sender?.username)
is MessageType.UserRemoved -> context.getString(R.string.message_user_removed_by, message.message, message.sender?.username)
is MessageType.MessagePinned -> {
val attachment = message.attachments?.get(0)
val pinnedSystemMessage = context.getString(R.string.message_pinned)
if (attachment != null && attachment is MessageAttachment) {
return SpannableStringBuilder(pinnedSystemMessage)
.apply {
setSpan(StyleSpan(Typeface.ITALIC), 0, length, 0)
setSpan(ForegroundColorSpan(Color.GRAY), 0, length, 0)
}
.append(quoteMessage(attachment.author!!, attachment.text!!, attachment.timestamp!!))
}
return pinnedSystemMessage
}
is MessageType.MessagePinned -> context.getString(R.string.message_pinned)
else -> {
throw InvalidParameterException("Invalid message type: ${message.type}")
}
}
//isSystemMessage = true
val spannableMsg = SpannableStringBuilder(content)
spannableMsg.setSpan(StyleSpan(Typeface.ITALIC), 0, spannableMsg.length,
0)
spannableMsg.setSpan(ForegroundColorSpan(Color.GRAY), 0, spannableMsg.length,
0)
/*if (attachmentType == null) {
val username = message.sender?.username
val message = message.message
val usernameTextStartIndex = if (username != null) content.indexOf(username) else -1
val usernameTextEndIndex = if (username != null) usernameTextStartIndex + username.length else -1
val messageTextStartIndex = if (message.isNotEmpty()) content.indexOf(message) else -1
val messageTextEndIndex = messageTextStartIndex + message.length
if (usernameTextStartIndex > -1) {
spannableMsg.setSpan(StyleSpan(Typeface.BOLD_ITALIC), usernameTextStartIndex, usernameTextEndIndex,
0)
}
if (messageTextStartIndex > -1) {
spannableMsg.setSpan(StyleSpan(Typeface.BOLD_ITALIC), messageTextStartIndex, messageTextEndIndex,
0)
}
} else if (attachmentType == AttachmentType.Message) {
spannableMsg.append(quoteMessage(attachmentMessageAuthor!!, attachmentMessageText!!, attachmentTimestamp!!))
}*/
return spannableMsg
}
private fun quoteMessage(author: String, text: String, timestamp: Long): CharSequence {
return SpannableStringBuilder().apply {
val header = "\n$author ${getTime(timestamp)}\n"
append(SpannableString(header).apply {
setSpan(StyleSpan(Typeface.BOLD), 1, author.length + 1, 0)
setSpan(MessageParser.QuoteMarginSpan(context.getDrawable(R.drawable.quote), 10), 1, length, 0)
setSpan(AbsoluteSizeSpan(context.resources.getDimensionPixelSize(R.dimen.message_time_text_size)),
author.length + 1, length, 0)
})
append(SpannableString(parser.renderMarkdown(text)).apply {
setSpan(MessageParser.QuoteMarginSpan(context.getDrawable(R.drawable.quote), 10), 0, length, 0)
})
}
}
}
\ No newline at end of file
......@@ -3,7 +3,7 @@ package chat.rocket.android.chatroom.viewmodel.suggestion
import chat.rocket.android.widget.autocompletion.model.SuggestionModel
import chat.rocket.common.model.UserStatus
class PeopleSuggestionViewModel(val imageUri: String,
class PeopleSuggestionViewModel(val imageUri: String?,
text: String,
val username: String,
val name: String,
......
package chat.rocket.android.chatrooms.presentation
import chat.rocket.android.chatroom.viewmodel.ViewModelMapper
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.helper.ChatRoomsSortOrder
import chat.rocket.android.helper.Constants
import chat.rocket.android.helper.SharedPreferenceHelper
import chat.rocket.android.main.presentation.MainNavigator
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.infraestructure.ConnectionManager
......@@ -9,7 +13,10 @@ import chat.rocket.android.server.infraestructure.chatRooms
import chat.rocket.android.server.infraestructure.state
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.common.RocketChatException
import chat.rocket.common.model.*
import chat.rocket.common.model.BaseRoom
import chat.rocket.common.model.RoomType
import chat.rocket.common.model.SimpleUser
import chat.rocket.common.model.User
import chat.rocket.core.internal.model.Subscription
import chat.rocket.core.internal.realtime.State
import chat.rocket.core.internal.realtime.StreamMessage
......@@ -22,6 +29,7 @@ import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.channels.Channel
import timber.log.Timber
import javax.inject.Inject
import kotlin.reflect.KProperty1
class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
private val strategy: CancelStrategy,
......@@ -30,13 +38,14 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
private val getChatRoomsInteractor: GetChatRoomsInteractor,
private val saveChatRoomsInteractor: SaveChatRoomsInteractor,
private val refreshSettingsInteractor: RefreshSettingsInteractor,
private val viewModelMapper: ViewModelMapper,
settingsRepository: SettingsRepository,
factory: ConnectionManagerFactory) {
private val manager: ConnectionManager = factory.create(serverInteractor.get()!!)
private val currentServer = serverInteractor.get()!!
private val client = manager.client
private var reloadJob: Deferred<List<ChatRoom>>? = null
private val settings = settingsRepository.get(currentServer)!!
private val settings = settingsRepository.get(currentServer)
private val subscriptionsChannel = Channel<StreamMessage<BaseRoom>>()
private val stateChannel = Channel<State>()
......@@ -89,9 +98,9 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
val chatRoomsCombined = mutableListOf<ChatRoom>()
chatRoomsCombined.addAll(usersToChatRooms(users))
chatRoomsCombined.addAll(roomsToChatRooms(rooms))
view.updateChatRooms(chatRoomsCombined)
view.updateChatRooms(getChatRoomsWithPreviews(chatRoomsCombined.toList()))
} else {
view.updateChatRooms(roomList)
view.updateChatRooms(getChatRoomsWithPreviews(roomList))
}
} catch (ex: RocketChatException) {
Timber.e(ex)
......@@ -101,21 +110,52 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
private suspend fun usersToChatRooms(users: List<User>): List<ChatRoom> {
return users.map {
ChatRoom(it.id, RoomType.DIRECT_MESSAGE, SimpleUser(
username = it.username, name = it.name, id = null), it.name ?: "",
it.name, false, null, null, null,
null, null, false, false, false,
0L, null, 0L, null, client
ChatRoom(id = it.id,
type = RoomType.DIRECT_MESSAGE,
user = SimpleUser(username = it.username, name = it.name, id = null),
name = it.name ?: "",
fullName = it.name,
readonly = false,
updatedAt = null,
timestamp = null,
lastSeen = null,
topic = null,
description = null,
announcement = null,
default = false,
open = false,
alert = false,
unread = 0L,
userMenstions = null,
groupMentions = 0L,
lastMessage = null,
client = client
)
}
}
private suspend fun roomsToChatRooms(rooms: List<Room>): List<ChatRoom> {
return rooms.map {
ChatRoom(it.id, it.type, it.user, it.name ?: "",
it.fullName, it.readonly, it.updatedAt, null, null,
it.topic, it.announcement, false, false, false,
0L, null, 0L, it.lastMessage, client
ChatRoom(id = it.id,
type = it.type,
user = it.user,
name = it.name ?: "",
fullName = it.fullName,
readonly = it.readonly,
updatedAt = it.updatedAt,
timestamp = null,
lastSeen = null,
topic = it.topic,
description = it.description,
announcement = it.announcement,
default = false,
open = false,
alert = false,
unread = 0L,
userMenstions = null,
groupMentions = 0L,
lastMessage = it.lastMessage,
client = client
)
}
}
......@@ -125,18 +165,72 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
val sortedRooms = sortRooms(chatRooms)
Timber.d("Loaded rooms: ${sortedRooms.size}")
saveChatRoomsInteractor.save(currentServer, sortedRooms)
return sortedRooms
return getChatRoomsWithPreviews(sortedRooms)
}
fun updateSortedChatRooms() {
val currentServer = serverInteractor.get()!!
launchUI(strategy) {
val roomList = getChatRoomsInteractor.get(currentServer)
view.updateChatRooms(sortRooms(roomList))
}
}
private fun sortRooms(chatRooms: List<ChatRoom>): List<ChatRoom> {
val sortType = SharedPreferenceHelper.getInt(Constants.CHATROOM_SORT_TYPE_KEY, ChatRoomsSortOrder.ACTIVITY)
val groupByType = SharedPreferenceHelper.getBoolean(Constants.CHATROOM_GROUP_BY_TYPE_KEY, false)
val openChatRooms = getOpenChatRooms(chatRooms)
return sortChatRooms(openChatRooms)
return when (sortType) {
ChatRoomsSortOrder.ALPHABETICAL -> {
when (groupByType) {
true -> openChatRooms.sortedWith(compareBy(ChatRoom::type).thenBy { it.name })
false -> openChatRooms.sortedWith(compareBy(ChatRoom::name))
}
}
ChatRoomsSortOrder.ACTIVITY -> {
when (groupByType) {
true -> openChatRooms.sortedWith(compareBy(ChatRoom::type).thenByDescending { it.lastMessage?.timestamp })
false -> openChatRooms.sortedByDescending { chatRoom ->
chatRoom.lastMessage?.timestamp
}
}
}
else -> {
openChatRooms
}
}
}
private fun compareBy(selector: KProperty1<ChatRoom, RoomType>): Comparator<ChatRoom> {
return Comparator { a, b -> getTypeConstant(a.type) - getTypeConstant(b.type) }
}
private fun getTypeConstant(roomType: RoomType): Int {
return when (roomType) {
is RoomType.Channel -> Constants.CHATROOM_CHANNEL
is RoomType.PrivateGroup -> Constants.CHATROOM_PRIVATE_GROUP
is RoomType.DirectMessage -> Constants.CHATROOM_DM
is RoomType.Livechat -> Constants.CHATROOM_LIVE_CHAT
else -> 0
}
}
private fun updateRooms() {
Timber.d("Updating Rooms")
launch {
view.updateChatRooms(getChatRoomsInteractor.get(currentServer))
launch(strategy.jobs) {
view.updateChatRooms(getChatRoomsWithPreviews(getChatRoomsInteractor.get(currentServer)))
}
}
private suspend fun getChatRoomsWithPreviews(chatRooms: List<ChatRoom>): List<ChatRoom> {
return chatRooms.map {
if (it.lastMessage != null) {
it.copy(lastMessage = viewModelMapper.map(it.lastMessage!!).last().preview)
} else {
it
}
}
}
......@@ -245,25 +339,27 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
val chatRooms = getChatRoomsInteractor.get(currentServer).toMutableList()
val chatRoom = chatRooms.find { chatRoom -> chatRoom.id == room.id }
chatRoom?.apply {
val newRoom = ChatRoom(room.id,
room.type,
room.user ?: user,
room.name ?: name,
room.fullName ?: fullName,
room.readonly,
room.updatedAt ?: updatedAt,
timestamp,
lastSeen,
room.topic,
room.announcement,
default,
open,
alert,
unread,
userMenstions,
groupMentions,
room.lastMessage,
client)
val newRoom = ChatRoom(id = room.id,
type = room.type,
user = room.user ?: user,
name = room.name ?: name,
fullName = room.fullName ?: fullName,
readonly = room.readonly,
updatedAt = room.updatedAt ?: updatedAt,
timestamp = timestamp,
lastSeen = lastSeen,
topic = room.topic,
description = room.description,
announcement = room.announcement,
default = default,
favorite = favorite,
open = open,
alert = alert,
unread = unread,
userMenstions = userMenstions,
groupMentions = groupMentions,
lastMessage = room.lastMessage,
client = client)
removeRoom(room.id, chatRooms)
chatRooms.add(newRoom)
saveChatRoomsInteractor.save(currentServer, sortRooms(chatRooms))
......@@ -272,29 +368,31 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
// Update a ChatRoom with a Subscription information
private fun updateSubscription(subscription: Subscription) {
Timber.d("Updating subscrition: ${subscription.id} - ${subscription.name}")
Timber.d("Updating subscription: ${subscription.id} - ${subscription.name}")
val chatRooms = getChatRoomsInteractor.get(currentServer).toMutableList()
val chatRoom = chatRooms.find { chatRoom -> chatRoom.id == subscription.roomId }
chatRoom?.apply {
val newRoom = ChatRoom(subscription.roomId,
subscription.type,
subscription.user ?: user,
subscription.name,
subscription.fullName ?: fullName,
subscription.readonly ?: readonly,
subscription.updatedAt ?: updatedAt,
subscription.timestamp ?: timestamp,
subscription.lastSeen ?: lastSeen,
topic,
announcement,
subscription.isDefault,
subscription.open,
subscription.alert,
subscription.unread,
subscription.userMentions,
subscription.groupMentions,
lastMessage,
client)
val newRoom = ChatRoom(id = subscription.roomId,
type = subscription.type,
user = subscription.user ?: user,
name = subscription.name,
fullName = subscription.fullName ?: fullName,
readonly = subscription.readonly ?: readonly,
updatedAt = subscription.updatedAt ?: updatedAt,
timestamp = subscription.timestamp ?: timestamp,
lastSeen = subscription.lastSeen ?: lastSeen,
topic = topic,
description = description,
announcement = announcement,
default = subscription.isDefault,
favorite = subscription.isFavorite,
open = subscription.open,
alert = subscription.alert,
unread = subscription.unread,
userMenstions = subscription.userMentions,
groupMentions = subscription.groupMentions,
lastMessage = lastMessage,
client = client)
removeRoom(subscription.roomId, chatRooms)
chatRooms.add(newRoom)
saveChatRoomsInteractor.save(currentServer, sortRooms(chatRooms))
......
......@@ -3,14 +3,18 @@ package chat.rocket.android.chatrooms.ui
import DateTimeHelper
import DrawableHelper
import android.content.Context
import android.graphics.drawable.Drawable
import android.graphics.Color
import android.support.v4.content.ContextCompat
import android.support.v7.widget.RecyclerView
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import chat.rocket.android.R
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.infrastructure.checkIfMyself
import chat.rocket.android.server.domain.PublicSettings
import chat.rocket.android.server.domain.useRealName
import chat.rocket.android.util.extensions.content
......@@ -26,6 +30,7 @@ import kotlinx.android.synthetic.main.unread_messages_badge.view.*
class ChatRoomsAdapter(private val context: Context,
private val settings: PublicSettings,
private val localRepository: LocalRepository,
private val listener: (ChatRoom) -> Unit) : RecyclerView.Adapter<ChatRoomsAdapter.ViewHolder>() {
var dataSet: MutableList<ChatRoom> = ArrayList()
......@@ -35,7 +40,7 @@ class ChatRoomsAdapter(private val context: Context,
override fun getItemCount(): Int = dataSet.size
fun updateRooms(newRooms: List<ChatRoom>) {
fun updateRooms(newRooms: List<ChatRoom>) {
dataSet.clear()
dataSet.addAll(newRooms)
}
......@@ -116,21 +121,30 @@ class ChatRoomsAdapter(private val context: Context,
val lastMessageSender = lastMessage?.sender
if (lastMessage != null && lastMessageSender != null) {
val message = lastMessage.message
val senderUsername = lastMessageSender.username
val senderUsername = if (settings.useRealName()) {
lastMessageSender.name ?: lastMessageSender.username
} else {
lastMessageSender.username
}
when (senderUsername) {
chatRoom.name -> {
textView.content = message
}
// TODO Change to MySelf
// chatRoom.user?.username -> {
// holder.lastMessage.textContent = context.getString(R.string.msg_you) + ": $message"
// }
else -> {
textView.content = "@$senderUsername: $message"
val user = if (localRepository.checkIfMyself(lastMessageSender.username!!)) {
"${context.getString(R.string.msg_you)}: "
} else {
"$senderUsername: "
}
val spannable = SpannableStringBuilder(user)
val len = spannable.length
spannable.setSpan(ForegroundColorSpan(Color.BLACK), 0, len - 1, 0)
spannable.append(message)
textView.content = spannable
}
}
} else {
textView.content = ""
textView.content = context.getText(R.string.msg_no_messages_yet)
}
}
......
package chat.rocket.android.chatrooms.ui
import android.app.AlertDialog
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.os.Handler
import android.support.v4.app.Fragment
......@@ -9,13 +12,20 @@ import android.support.v7.widget.DefaultItemAnimator
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.SearchView
import android.view.*
import android.widget.CheckBox
import android.widget.RadioGroup
import chat.rocket.android.R
import chat.rocket.android.chatrooms.presentation.ChatRoomsPresenter
import chat.rocket.android.chatrooms.presentation.ChatRoomsView
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.helper.ChatRoomsSortOrder
import chat.rocket.android.helper.Constants
import chat.rocket.android.helper.SharedPreferenceHelper
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.SettingsRepository
import chat.rocket.android.util.extensions.*
import chat.rocket.android.widget.DividerItemDecoration
import chat.rocket.common.model.RoomType
import chat.rocket.core.internal.realtime.State
import chat.rocket.core.model.ChatRoom
import dagger.android.support.AndroidSupportInjection
......@@ -27,14 +37,18 @@ import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.launch
import javax.inject.Inject
class ChatRoomsFragment : Fragment(), ChatRoomsView {
@Inject lateinit var presenter: ChatRoomsPresenter
@Inject lateinit var serverInteractor: GetCurrentServerInteractor
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var localRepository: LocalRepository
private lateinit var preferences: SharedPreferences
private var searchView: SearchView? = null
private val handler = Handler()
private var listJob: Job? = null
private var sectionedAdapter: SimpleSectionedRecyclerViewAdapter? = null
companion object {
fun newInstance() = ChatRoomsFragment()
......@@ -44,6 +58,7 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
super.onCreate(savedInstanceState)
AndroidSupportInjection.inject(this)
setHasOptionsMenu(true)
preferences = context?.getSharedPreferences("temp", Context.MODE_PRIVATE)!!
}
override fun onDestroy() {
......@@ -85,20 +100,76 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
})
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.action_sort -> {
val dialogLayout = layoutInflater.inflate(R.layout.chatroom_sort_dialog, null)
val sortType = SharedPreferenceHelper.getInt(Constants.CHATROOM_SORT_TYPE_KEY, ChatRoomsSortOrder.ACTIVITY)
val groupByType = SharedPreferenceHelper.getBoolean(Constants.CHATROOM_GROUP_BY_TYPE_KEY, false)
val radioGroup = dialogLayout.findViewById<RadioGroup>(R.id.radio_group_sort)
val groupByTypeCheckBox = dialogLayout.findViewById<CheckBox>(R.id.checkbox_group_by_type)
radioGroup.check(when (sortType) {
0 -> R.id.radio_sort_alphabetical
else -> R.id.radio_sort_activity
})
radioGroup.setOnCheckedChangeListener({ _, checkedId ->
run {
SharedPreferenceHelper.putInt(Constants.CHATROOM_SORT_TYPE_KEY, when (checkedId) {
R.id.radio_sort_alphabetical -> 0
R.id.radio_sort_activity -> 1
else -> 1
})
presenter.updateSortedChatRooms()
invalidateQueryOnSearch()
}
})
groupByTypeCheckBox.isChecked = groupByType
groupByTypeCheckBox.setOnCheckedChangeListener({ _, isChecked ->
SharedPreferenceHelper.putBoolean(Constants.CHATROOM_GROUP_BY_TYPE_KEY, isChecked)
presenter.updateSortedChatRooms()
invalidateQueryOnSearch()
})
val dialogSort = AlertDialog.Builder(context)
.setTitle(R.string.dialog_sort_title)
.setView(dialogLayout)
.setPositiveButton("Done", { dialog, _ -> dialog.dismiss() })
dialogSort.show()
}
}
return super.onOptionsItemSelected(item)
}
private fun invalidateQueryOnSearch(){
searchView?.let {
if (!searchView!!.isIconified){
queryChatRoomsByName(searchView!!.query.toString())
}
}
}
override suspend fun updateChatRooms(newDataSet: List<ChatRoom>) {
activity?.apply {
listJob?.cancel()
listJob = launch(UI) {
val adapter = recycler_view.adapter as ChatRoomsAdapter
val adapter = recycler_view.adapter as SimpleSectionedRecyclerViewAdapter
// FIXME https://fabric.io/rocketchat3/android/apps/chat.rocket.android.dev/issues/5a90d4718cb3c2fa63b3f557?time=last-seven-days
// TODO - fix this bug to reenable DiffUtil
val diff = async(CommonPool) {
DiffUtil.calculateDiff(RoomsDiffCallback(adapter.dataSet, newDataSet))
DiffUtil.calculateDiff(RoomsDiffCallback(adapter.baseAdapter.dataSet, newDataSet))
}.await()
if (isActive) {
adapter.updateRooms(newDataSet)
adapter.baseAdapter.updateRooms(newDataSet)
diff.dispatchUpdatesTo(adapter)
//Set sections always after data set is updated
setSections()
}
}
}
......@@ -108,11 +179,19 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
override fun showLoading() = view_loading.setVisible(true)
override fun hideLoading() = view_loading.setVisible(false)
override fun hideLoading() {
if (view_loading != null) {
view_loading.setVisible(false)
}
}
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int) {
showToast(resId)
}
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
......@@ -148,15 +227,49 @@ class ChatRoomsFragment : Fragment(), ChatRoomsView {
activity?.apply {
recycler_view.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
recycler_view.addItemDecoration(DividerItemDecoration(this,
resources.getDimensionPixelSize(R.dimen.divider_item_decorator_bound_start),
resources.getDimensionPixelSize(R.dimen.divider_item_decorator_bound_end)))
resources.getDimensionPixelSize(R.dimen.divider_item_decorator_bound_start),
resources.getDimensionPixelSize(R.dimen.divider_item_decorator_bound_end)))
recycler_view.itemAnimator = DefaultItemAnimator()
// TODO - use a ViewModel Mapper instead of using settings on the adapter
recycler_view.adapter = ChatRoomsAdapter(this,
settingsRepository.get(serverInteractor.get()!!)!!) { chatRoom ->
presenter.loadChatRoom(chatRoom)
val baseAdapter = ChatRoomsAdapter(this,
settingsRepository.get(serverInteractor.get()!!), localRepository) { chatRoom -> presenter.loadChatRoom(chatRoom) }
sectionedAdapter = SimpleSectionedRecyclerViewAdapter(this, R.layout.item_chatroom_header, R.id.text_chatroom_header, baseAdapter!!)
recycler_view.adapter = sectionedAdapter
}
}
private fun setSections() {
//Don't add section if not grouping by RoomType
if (!SharedPreferenceHelper.getBoolean(Constants.CHATROOM_GROUP_BY_TYPE_KEY, false)) {
sectionedAdapter?.clearSections()
return
}
val sections = ArrayList<SimpleSectionedRecyclerViewAdapter.Section>()
sectionedAdapter?.baseAdapter?.dataSet?.let {
var previousChatRoomType = ""
for ((position, chatRoom) in it.withIndex()) {
val type = chatRoom.type.toString()
if (type != previousChatRoomType) {
val title = when (type) {
RoomType.CHANNEL.toString() -> resources.getString(R.string.header_channel)
RoomType.PRIVATE_GROUP.toString() -> resources.getString(R.string.header_private_groups)
RoomType.DIRECT_MESSAGE.toString() -> resources.getString(R.string.header_direct_messages)
RoomType.LIVECHAT.toString() -> resources.getString(R.string.header_live_chats)
else -> resources.getString(R.string.header_unknown)
}
sections.add(SimpleSectionedRecyclerViewAdapter.Section(position, title))
}
previousChatRoomType = chatRoom.type.toString()
}
}
val dummy = arrayOfNulls<SimpleSectionedRecyclerViewAdapter.Section>(sections.size)
sectionedAdapter?.setSections(sections.toArray(dummy))
}
private fun queryChatRoomsByName(name: String?): Boolean {
......
package chat.rocket.android.chatrooms.ui
import android.content.Context
import android.support.v7.widget.RecyclerView
import android.util.SparseArray
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import java.util.*
class SimpleSectionedRecyclerViewAdapter(private val context: Context, private val sectionResourceId: Int, private val textResourceId: Int,
val baseAdapter: ChatRoomsAdapter) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var isValid = true
private val sectionsHeaders = SparseArray<Section>()
init {
baseAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
isValid = baseAdapter.itemCount > 0
notifyDataSetChanged()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
isValid = baseAdapter.itemCount > 0
notifyItemRangeChanged(positionStart, itemCount)
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
isValid = baseAdapter.itemCount > 0
notifyItemRangeInserted(positionStart, itemCount)
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
isValid = baseAdapter.itemCount > 0
notifyItemRangeRemoved(positionStart, itemCount)
}
})
}
class SectionViewHolder(view: View, textResourceId: Int) : RecyclerView.ViewHolder(view) {
var title: TextView = view.findViewById<View>(textResourceId) as TextView
}
override fun onCreateViewHolder(parent: ViewGroup, typeView: Int): RecyclerView.ViewHolder {
return if (typeView == SECTION_TYPE) {
val view = LayoutInflater.from(context).inflate(sectionResourceId, parent, false)
SectionViewHolder(view, textResourceId)
} else {
baseAdapter.onCreateViewHolder(parent, typeView - 1)
}
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
if (isSectionHeaderPosition(position)) {
(viewHolder as SectionViewHolder).title.text = sectionsHeaders.get(position).title
} else {
baseAdapter.onBindViewHolder(viewHolder as ChatRoomsAdapter.ViewHolder, sectionedPositionToPosition(position))
}
}
override fun getItemViewType(position: Int): Int {
return if (isSectionHeaderPosition(position))
SECTION_TYPE
else
baseAdapter.getItemViewType(sectionedPositionToPosition(position)) + 1
}
class Section(internal var firstPosition: Int, var title: CharSequence) {
internal var sectionedPosition: Int = 0
}
fun setSections(sections: Array<Section>) {
sectionsHeaders.clear()
Arrays.sort(sections) { section1, section2 ->
when {
section1.firstPosition == section2.firstPosition -> 0
section1.firstPosition < section2.firstPosition -> -1
else -> 1
}
}
for ((offset, section) in sections.withIndex()) {
section.sectionedPosition = section.firstPosition + offset
sectionsHeaders.append(section.sectionedPosition, section)
}
notifyDataSetChanged()
}
fun clearSections(){
sectionsHeaders.clear()
notifyDataSetChanged()
}
fun positionToSectionedPosition(position: Int): Int {
var offset = 0
for (i in 0 until sectionsHeaders.size()) {
if (sectionsHeaders.valueAt(i).firstPosition > position) {
break
}
++offset
}
return position + offset
}
private fun sectionedPositionToPosition(sectionedPosition: Int): Int {
if (isSectionHeaderPosition(sectionedPosition)) {
return RecyclerView.NO_POSITION
}
var offset = 0
for (i in 0 until sectionsHeaders.size()) {
if (sectionsHeaders.valueAt(i).sectionedPosition > sectionedPosition) {
break
}
--offset
}
return sectionedPosition + offset
}
private fun isSectionHeaderPosition(position: Int): Boolean {
return sectionsHeaders.get(position) != null
}
override fun getItemId(position: Int): Long {
return when (isSectionHeaderPosition(position)) {
true -> (Integer.MAX_VALUE - sectionsHeaders.indexOfKey(position)).toLong()
false -> baseAdapter.getItemId(sectionedPositionToPosition(position))
}
}
override fun getItemCount(): Int {
return if (isValid) baseAdapter.itemCount + sectionsHeaders.size() else 0
}
companion object {
private const val SECTION_TYPE = 0
}
}
......@@ -4,6 +4,7 @@ import android.app.Application
import chat.rocket.android.app.RocketChatApplication
import chat.rocket.android.dagger.module.ActivityBuilder
import chat.rocket.android.dagger.module.AppModule
import chat.rocket.android.dagger.module.ReceiverBuilder
import chat.rocket.android.dagger.module.ServiceBuilder
import chat.rocket.android.push.FirebaseTokenService
import dagger.BindsInstance
......@@ -12,7 +13,8 @@ import dagger.android.support.AndroidSupportInjectionModule
import javax.inject.Singleton
@Singleton
@Component(modules = [AndroidSupportInjectionModule::class, AppModule::class, ActivityBuilder::class, ServiceBuilder::class])
@Component(modules = [AndroidSupportInjectionModule::class,
AppModule::class, ActivityBuilder::class, ServiceBuilder::class, ReceiverBuilder::class])
interface AppComponent {
@Component.Builder
......
......@@ -16,6 +16,8 @@ import chat.rocket.android.main.di.MainModule
import chat.rocket.android.main.ui.MainActivity
import chat.rocket.android.members.di.MembersFragmentProvider
import chat.rocket.android.profile.di.ProfileFragmentProvider
import chat.rocket.android.server.di.ChangeServerModule
import chat.rocket.android.server.ui.ChangeServerActivity
import chat.rocket.android.settings.password.di.PasswordFragmentProvider
import chat.rocket.android.settings.password.ui.PasswordActivity
import dagger.Module
......@@ -51,4 +53,8 @@ abstract class ActivityBuilder {
@PerActivity
@ContributesAndroidInjector(modules = [PasswordFragmentProvider::class])
abstract fun bindPasswordActivity(): PasswordActivity
@PerActivity
@ContributesAndroidInjector(modules = [ChangeServerModule::class])
abstract fun bindChangeServerActivity(): ChangeServerActivity
}
\ No newline at end of file
package chat.rocket.android.dagger.module
import android.app.Application
import android.app.NotificationManager
import android.arch.persistence.room.Room
import android.content.Context
import android.content.SharedPreferences
import androidx.content.systemService
import chat.rocket.android.BuildConfig
import chat.rocket.android.R
import chat.rocket.android.app.RocketChatDatabase
import chat.rocket.android.authentication.infraestructure.MemoryTokenRepository
import chat.rocket.android.authentication.infraestructure.SharedPreferencesMultiServerTokenRepository
import chat.rocket.android.authentication.infraestructure.SharedPreferencesTokenRepository
import chat.rocket.android.dagger.qualifier.ForFresco
import chat.rocket.android.helper.FrescoAuthInterceptor
import chat.rocket.android.helper.MessageParser
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.infrastructure.SharedPrefsLocalRepository
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.infraestructure.*
import chat.rocket.android.push.GroupedPush
import chat.rocket.android.server.domain.AccountsRepository
import chat.rocket.android.server.domain.ChatRoomsRepository
import chat.rocket.android.server.domain.CurrentServerRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.GetPermissionsInteractor
import chat.rocket.android.server.domain.MessagesRepository
import chat.rocket.android.server.domain.MultiServerTokenRepository
import chat.rocket.android.server.domain.RoomRepository
import chat.rocket.android.server.domain.SettingsRepository
import chat.rocket.android.server.domain.TokenRepository
import chat.rocket.android.server.domain.UsersRepository
import chat.rocket.android.server.infraestructure.MemoryChatRoomsRepository
import chat.rocket.android.server.infraestructure.MemoryMessagesRepository
import chat.rocket.android.server.infraestructure.MemoryRoomRepository
import chat.rocket.android.server.infraestructure.MemoryUsersRepository
import chat.rocket.android.server.infraestructure.ServerDao
import chat.rocket.android.server.infraestructure.SharedPreferencesAccountsRepository
import chat.rocket.android.server.infraestructure.SharedPreferencesSettingsRepository
import chat.rocket.android.server.infraestructure.SharedPrefsCurrentServerRepository
import chat.rocket.android.util.AppJsonAdapterFactory
import chat.rocket.android.util.TimberLogger
import chat.rocket.common.internal.FallbackSealedClassJsonAdapter
import chat.rocket.common.util.PlatformLogger
import chat.rocket.core.RocketChatClient
import chat.rocket.core.TokenRepository
import com.facebook.drawee.backends.pipeline.DraweeConfig
import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory
import com.facebook.imagepipeline.core.ImagePipelineConfig
......@@ -110,8 +130,8 @@ class AppModule {
@Provides
@ForFresco
@Singleton
fun provideFrescoAuthIntercepter(tokenRepository: TokenRepository): Interceptor {
return FrescoAuthInterceptor(tokenRepository)
fun provideFrescoAuthIntercepter(tokenRepository: TokenRepository, currentServerInteractor: GetCurrentServerInteractor): Interceptor {
return FrescoAuthInterceptor(tokenRepository, currentServerInteractor)
}
@Provides
......@@ -144,8 +164,8 @@ class AppModule {
@Provides
@Singleton
fun provideTokenRepository(): TokenRepository {
return MemoryTokenRepository()
fun provideTokenRepository(prefs: SharedPreferences, moshi: Moshi): TokenRepository {
return SharedPreferencesTokenRepository(prefs, moshi)
}
@Provides
......@@ -192,7 +212,10 @@ class AppModule {
@Provides
@Singleton
fun provideMoshi(): Moshi {
return Moshi.Builder().add(AppJsonAdapterFactory.INSTANCE).build()
return Moshi.Builder()
.add(FallbackSealedClassJsonAdapter.ADAPTER_FACTORY)
.add(AppJsonAdapterFactory.INSTANCE)
.build()
}
@Provides
......@@ -240,4 +263,16 @@ class AppModule {
fun providePermissionInteractor(settingsRepository: SettingsRepository, serverRepository: CurrentServerRepository): GetPermissionsInteractor {
return GetPermissionsInteractor(settingsRepository, serverRepository)
}
@Provides
@Singleton
fun provideAccountsRepository(preferences: SharedPreferences, moshi: Moshi): AccountsRepository =
SharedPreferencesAccountsRepository(preferences, moshi)
@Provides
fun provideNotificationManager(context: Context): NotificationManager = context.systemService()
@Provides
@Singleton
fun provideGroupedPush() = GroupedPush()
}
\ No newline at end of file
package chat.rocket.android.dagger.module
import chat.rocket.android.push.DeleteReceiver
import chat.rocket.android.push.di.DeleteReceiverProvider
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class ReceiverBuilder {
@ContributesAndroidInjector(modules = [DeleteReceiverProvider::class])
abstract fun bindDeleteReceiver(): DeleteReceiver
}
\ No newline at end of file
package chat.rocket.android.dagger.module
import chat.rocket.android.push.FirebaseTokenService
import chat.rocket.android.push.GcmListenerService
import chat.rocket.android.push.di.FirebaseTokenServiceProvider
import chat.rocket.android.push.di.GcmListenerServiceProvider
import dagger.Module
import dagger.android.ContributesAndroidInjector
......@@ -9,4 +11,7 @@ import dagger.android.ContributesAndroidInjector
@ContributesAndroidInjector(modules = [FirebaseTokenServiceProvider::class])
abstract fun bindFirebaseTokenService(): FirebaseTokenService
@ContributesAndroidInjector(modules = [GcmListenerServiceProvider::class])
abstract fun bindGcmListenerService(): GcmListenerService
}
\ No newline at end of file
package chat.rocket.android.helper
object Constants {
const val CHATROOM_SORT_TYPE_KEY: String = "chatroom_sort_type"
const val CHATROOM_GROUP_BY_TYPE_KEY: String = "chatroom_group_by_type"
const val CHATROOM_GROUP_FAVOURITES_KEY: String = "chatroom_group_favourites"
//Used to sort chat rooms
const val CHATROOM_CHANNEL = 0
const val CHATROOM_PRIVATE_GROUP = 1
const val CHATROOM_DM = 2
const val CHATROOM_LIVE_CHAT = 3
}
object ChatRoomsSortOrder {
const val ALPHABETICAL: Int = 0
const val ACTIVITY: Int = 1
}
\ No newline at end of file
package chat.rocket.android.helper
import chat.rocket.core.TokenRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.TokenRepository
import okhttp3.Interceptor
import okhttp3.Response
class FrescoAuthInterceptor(private val tokenRepository: TokenRepository) : Interceptor {
class FrescoAuthInterceptor(
private val tokenRepository: TokenRepository,
private val currentServerInteractor: GetCurrentServerInteractor
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenRepository.get()
var request = chain.request()
currentServerInteractor.get()?.let { serverUrl ->
val token = tokenRepository.get(serverUrl)
token?.let {
val url = request.url().newBuilder().apply {
addQueryParameter("rc_uid", token.userId)
addQueryParameter("rc_token", token.authToken)
}.build()
request = request.newBuilder().apply {
url(url)
}.build()
}
return@let token?.let {
val url = request.url().newBuilder().apply {
addQueryParameter("rc_uid", token.userId)
addQueryParameter("rc_token", token.authToken)
}.build()
request = request.newBuilder().apply {
url(url)
}.build()
}
}
return chain.proceed(request)
}
}
\ No newline at end of file
......@@ -4,158 +4,113 @@ import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.*
import android.graphics.drawable.Drawable
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.net.Uri
import android.support.customtabs.CustomTabsIntent
import android.provider.Browser
import android.support.v4.content.ContextCompat
import android.support.v4.content.res.ResourcesCompat
import android.text.Layout
import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.text.style.*
import android.text.style.ClickableSpan
import android.text.style.ReplacementSpan
import android.util.Patterns
import android.view.View
import chat.rocket.android.R
import chat.rocket.android.chatroom.viewmodel.MessageViewModel
import chat.rocket.android.widget.emoji.EmojiParser
import chat.rocket.android.widget.emoji.EmojiRepository
import chat.rocket.android.widget.emoji.EmojiTypefaceSpan
import chat.rocket.common.model.SimpleUser
import chat.rocket.core.model.Message
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.BlockQuote
import org.commonmark.node.Document
import org.commonmark.node.Text
import ru.noties.markwon.Markwon
import ru.noties.markwon.SpannableBuilder
import ru.noties.markwon.SpannableConfiguration
import ru.noties.markwon.renderer.SpannableMarkdownVisitor
import timber.log.Timber
import java.util.regex.Pattern
import javax.inject.Inject
class MessageParser @Inject constructor(val context: Application, private val configuration: SpannableConfiguration) {
private val parser = Markwon.createParser()
private val regexUsername = Pattern.compile("([^\\S]|^)+(@[\\w.\\-]+)",
Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
private val selfReferList = listOf("@all", "@here")
/**
* Render a markdown text message to Spannable.
*
* @param text The text message containing markdown syntax.
* @param quote An optional message to be quoted either by a quote or reply action.
* @param urls A list of urls to convert to markdown link syntax.
* @param message The [Message] object we're interested on rendering.
* @param selfUsername This user username.
*
* @return A Spannable with the parsed markdown.
*/
fun renderMarkdown(text: String, quote: MessageViewModel? = null, selfUsername: String? = null): CharSequence {
fun renderMarkdown(message: Message, selfUsername: String? = null): CharSequence {
val text = message.message
val builder = SpannableBuilder()
val content = EmojiRepository.shortnameToUnicode(text, true)
val parentNode = parser.parse(toLenientMarkdown(content))
parentNode.accept(QuoteMessageBodyVisitor(context, configuration, builder))
quote?.apply {
var quoteNode = parser.parse("> $senderName $time")
parentNode.appendChild(quoteNode)
quoteNode.accept(QuoteMessageSenderVisitor(context, configuration, builder, senderName.length))
quoteNode = parser.parse("> ${toLenientMarkdown(quote.rawData.message)}")
quoteNode.accept(EmojiVisitor(builder))
quoteNode.accept(QuoteMessageBodyVisitor(context, configuration, builder))
}
parentNode.accept(SpannableMarkdownVisitor(configuration, builder))
parentNode.accept(LinkVisitor(builder))
parentNode.accept(EmojiVisitor(builder))
val result = builder.text()
applySpans(result, selfUsername)
return result
}
private fun applySpans(text: CharSequence, currentUser: String?) {
if (text !is Spannable) return
applyMentionSpans(text, currentUser)
}
private fun applyMentionSpans(text: CharSequence, currentUser: String?) {
val matcher = regexUsername.matcher(text)
val result = text as Spannable
while (matcher.find()) {
val user = matcher.group(2)
val start = matcher.start(2)
//TODO: should check if username actually exists prior to applying.
with(context) {
val referSelf = when (user) {
in selfReferList -> true
"@$currentUser" -> true
else -> false
}
val mentionTextColor: Int
val mentionBgColor: Int
if (referSelf) {
mentionTextColor = ResourcesCompat.getColor(resources, R.color.white, theme)
mentionBgColor = ResourcesCompat.getColor(context.resources,
R.color.colorAccent, theme)
} else {
mentionTextColor = ResourcesCompat.getColor(resources, R.color.colorAccent,
theme)
mentionBgColor = ResourcesCompat.getColor(resources,
android.R.color.transparent, theme)
}
val padding = resources.getDimensionPixelSize(R.dimen.padding_mention).toFloat()
val radius = resources.getDimensionPixelSize(R.dimen.radius_mention).toFloat()
val usernameSpan = MentionSpan(mentionBgColor, mentionTextColor, radius, padding,
referSelf)
result.setSpan(usernameSpan, start, start + user.length, 0)
}
parentNode.accept(EmojiVisitor(configuration, builder))
message.mentions?.let {
parentNode.accept(MentionVisitor(context, builder, it, selfUsername))
}
return builder.text()
}
/**
* Convert to a lenient markdown consistent with Rocket.Chat web markdown instead of the official specs.
*/
// Convert to a lenient markdown consistent with Rocket.Chat web markdown instead of the official specs.
private fun toLenientMarkdown(text: String): String {
return text.trim().replace("\\*(.+)\\*".toRegex()) { "**${it.groupValues[1].trim()}**" }
.replace("\\~(.+)\\~".toRegex()) { "~~${it.groupValues[1].trim()}~~" }
.replace("\\_(.+)\\_".toRegex()) { "_${it.groupValues[1].trim()}_" }
}
class QuoteMessageSenderVisitor(private val context: Context,
configuration: SpannableConfiguration,
private val builder: SpannableBuilder,
private val senderNameLength: Int) : SpannableMarkdownVisitor(configuration, builder) {
override fun visit(blockQuote: BlockQuote) {
// mark current length
val length = builder.length()
// pass to super to apply markdown
super.visit(blockQuote)
val res = context.resources
val timeOffsetStart = length + senderNameLength + 1
builder.setSpan(QuoteMarginSpan(context.getDrawable(R.drawable.quote), 10), length, builder.length())
builder.setSpan(StyleSpan(Typeface.BOLD), length, length + senderNameLength)
builder.setSpan(ForegroundColorSpan(Color.BLACK), length, builder.length())
// set time spans
builder.setSpan(AbsoluteSizeSpan(res.getDimensionPixelSize(R.dimen.message_time_text_size)),
timeOffsetStart, builder.length())
builder.setSpan(ForegroundColorSpan(ContextCompat.getColor(context, R.color.darkGray)),
timeOffsetStart, builder.length())
class MentionVisitor(context: Context,
private val builder: SpannableBuilder,
private val mentions: List<SimpleUser>,
private val currentUser: String?) : AbstractVisitor() {
private val othersTextColor = ResourcesCompat.getColor(context.resources, R.color.colorAccent, context.theme)
private val othersBackgroundColor = ResourcesCompat.getColor(context.resources, android.R.color.transparent, context.theme)
private val myselfTextColor = ResourcesCompat.getColor(context.resources, R.color.white, context.theme)
private val myselfBackgroundColor = ResourcesCompat.getColor(context.resources, R.color.colorAccent, context.theme)
private val mentionPadding = context.resources.getDimensionPixelSize(R.dimen.padding_mention).toFloat()
private val mentionRadius = context.resources.getDimensionPixelSize(R.dimen.radius_mention).toFloat()
override fun visit(t: Text) {
val text = t.literal
val mentionsList = mentions.map { it.username }.toMutableList()
mentionsList.add("all")
mentionsList.add("here")
mentionsList.toList().forEach {
if (it != null) {
val mentionMe = it == currentUser || it == "all" || it == "here"
var offset = text.indexOf("@$it", 0, true)
while (offset > -1) {
val textColor = if (mentionMe) myselfTextColor else othersTextColor
val backgroundColor = if (mentionMe) myselfBackgroundColor else othersBackgroundColor
val usernameSpan = MentionSpan(backgroundColor, textColor, mentionRadius, mentionPadding,
mentionMe)
// Add 1 to end offset to include the @.
val end = offset + it.length + 1
builder.setSpan(usernameSpan, offset, end, 0)
offset = text.indexOf("@$it", end, true)
}
}
}
}
}
class EmojiVisitor(private val builder: SpannableBuilder) : AbstractVisitor() {
override fun visit(text: Text) {
val spannable = EmojiParser.parse(text.literal)
class EmojiVisitor(configuration: SpannableConfiguration, private val builder: SpannableBuilder)
: SpannableMarkdownVisitor(configuration, builder) {
override fun visit(document: Document) {
val spannable = EmojiParser.parse(builder.text())
if (spannable is Spanned) {
val spans = spannable.getSpans(0, spannable.length, EmojiTypefaceSpan::class.java)
spans.forEach {
builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), 0)
}
}
visitChildren(text)
}
}
......@@ -195,60 +150,6 @@ class MessageParser @Inject constructor(val context: Application, private val co
}
}
class QuoteMessageBodyVisitor(private val context: Context,
configuration: SpannableConfiguration,
private val builder: SpannableBuilder) : SpannableMarkdownVisitor(configuration, builder) {
override fun visit(blockQuote: BlockQuote) {
// mark current length
val length = builder.length()
// pass to super to apply markdown
super.visit(blockQuote)
val padding = context.resources.getDimensionPixelSize(R.dimen.padding_quote)
builder.setSpan(QuoteMarginSpan(context.getDrawable(R.drawable.quote), padding), length,
builder.length())
}
}
class QuoteMarginSpan(quoteDrawable: Drawable, private var pad: Int) : LeadingMarginSpan, LineHeightSpan {
private val drawable: Drawable = quoteDrawable
override fun getLeadingMargin(first: Boolean): Int {
return drawable.intrinsicWidth + pad
}
override fun drawLeadingMargin(c: Canvas, p: Paint, x: Int, dir: Int,
top: Int, baseline: Int, bottom: Int,
text: CharSequence, start: Int, end: Int,
first: Boolean, layout: Layout) {
val st = (text as Spanned).getSpanStart(this)
val ix = x
val itop = layout.getLineTop(layout.getLineForOffset(st))
val dw = drawable.intrinsicWidth
val dh = drawable.intrinsicHeight
// XXX What to do about Paint?
drawable.setBounds(ix, itop, ix + dw, itop + layout.height)
drawable.draw(c)
}
override fun chooseHeight(text: CharSequence, start: Int, end: Int,
spanstartv: Int, v: Int,
fm: Paint.FontMetricsInt) {
if (end == (text as Spanned).getSpanEnd(this)) {
val ht = drawable.intrinsicHeight
var need = ht - (v + fm.descent - fm.ascent - spanstartv)
if (need > 0)
fm.descent += need
need = ht - (v + fm.bottom - fm.top - spanstartv)
if (need > 0)
fm.bottom += need
}
}
}
class MentionSpan(private val backgroundColor: Int,
private val textColor: Int,
private val radius: Float,
......@@ -274,13 +175,11 @@ class MessageParser @Inject constructor(val context: Application, private val co
bottom: Int,
paint: Paint) {
val length = paint.measureText(text.subSequence(start, end).toString())
val rect = RectF(x, top.toFloat(), x + length + padding * 2,
bottom.toFloat())
paint.setColor(backgroundColor)
val rect = RectF(x, top.toFloat(), x + length + padding * 2, bottom.toFloat())
paint.color = backgroundColor
canvas.drawRoundRect(rect, radius, radius, paint)
paint.setColor(textColor)
paint.color = textColor
canvas.drawText(text, start, end, x + padding, y.toFloat(), paint)
}
}
}
\ No newline at end of file
package chat.rocket.android.helper
object OauthHelper {
/**
* Returns the Github Oauth URL.
*
* @param clientId The GitHub client ID.
* @param state An unguessable random string used to protect against forgery attacks.
* @return The Github Oauth URL.
*/
fun getGithubOauthUrl(clientId: String, state: String): String {
return "https://github.com/login/oauth/authorize" +
"?client_id=$clientId" +
"&state=$state" +
"&scope=user:email"
}
/**
* Returns the Google Oauth URL.
*
* @param clientId The Google client ID.
* @param serverUrl The server URL.
* @param state An unguessable random string used to protect against forgery attacks.
* @return The Google Oauth URL.
*/
fun getGoogleOauthUrl(clientId: String, serverUrl: String, state: String): String {
return "https://accounts.google.com/o/oauth2/v2/auth" +
"?client_id=$clientId" +
"&redirect_uri=${UrlHelper.removeTrailingSlash(serverUrl)}/_oauth/google?close" +
"&state=$state" +
"&response_type=code" +
"&scope=email%20profile"
}
/**
* Returns the Linkedin Oauth URL.
*
* @param clientId The Linkedin client ID.
* @param serverUrl The server URL.
* @param state An unguessable random string used to protect against forgery attacks.
* @return The Linkedin Oauth URL.
*/
fun getLinkedinOauthUrl(clientId: String, serverUrl: String, state: String): String {
return "https://linkedin.com/oauth/v2/authorization" +
"?client_id=$clientId" +
"&redirect_uri=${UrlHelper.removeTrailingSlash(serverUrl)}/_oauth/linkedin?close" +
"&state=$state" +
"&response_type=code"
}
/**
* Returns the Gitlab Oauth URL.
*
* @param clientId The Gitlab client ID.
* @param serverUrl The server URL.
* @param state An unguessable random string used to protect against forgery attacks.
* @return The Gitlab Oauth URL.
*/
fun getGitlabOauthUrl(clientId: String, serverUrl: String, state: String): String {
return "https://gitlab.com/oauth/authorize" +
"?client_id=$clientId" +
"&redirect_uri=${UrlHelper.removeTrailingSlash(serverUrl)}/_oauth/gitlab?close" +
"&state=$state" +
"&response_type=code" +
"&scope=read_user"
}
}
\ No newline at end of file
package chat.rocket.android.helper
import android.content.SharedPreferences
import android.preference.PreferenceManager
import chat.rocket.android.app.RocketChatApplication
object SharedPreferenceHelper {
private var sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(RocketChatApplication.getAppContext())
private var editor: SharedPreferences.Editor? = sharedPreferences.edit()
//Add more methods for other types if needed
fun putInt(key: String, value: Int) {
editor!!.putInt(key, value).apply()
}
fun getInt(key: String, defaultValue: Int): Int {
return sharedPreferences.getInt(key, defaultValue)
}
fun putLong(key: String, value: Long) {
editor!!.putLong(key, value).apply()
}
fun getLong(key: String, defaultValue: Long): Long {
return sharedPreferences.getLong(key, defaultValue)
}
fun putString(key: String, value: String) {
editor!!.putString(key, value).apply()
}
fun getString(key: String, defaultValue: String): String? {
return sharedPreferences.getString(key, defaultValue)
}
fun putBoolean(key: String, value: Boolean) {
editor!!.putBoolean(key, value).apply()
}
fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return sharedPreferences.getBoolean(key, defaultValue)
}
fun remove(key: String) {
editor!!.remove(key).apply()
}
}
\ No newline at end of file
......@@ -14,6 +14,16 @@ object UrlHelper {
fun getAvatarUrl(serverUrl: String, avatarName: String, format: String = "jpeg"): String =
removeTrailingSlash(serverUrl) + "/avatar/" + removeTrailingSlash(avatarName) + "?format=$format"
/**
* Returns the server logo URL.
*
* @param serverUrl The server URL.
* @param favicon The faviconLarge from the server settings.
* @return The server logo URL.
*/
fun getServerLogoUrl(serverUrl: String, favicon: String): String =
removeTrailingSlash(serverUrl) + "/$favicon"
/**
* Returns the CAS URL.
*
......
......@@ -2,19 +2,28 @@ package chat.rocket.android.infrastructure
interface LocalRepository {
fun save(key: String, value: String?)
fun save(key: String, value: Boolean)
fun save(key: String, value: Int)
fun save(key: String, value: Long)
fun save(key: String, value: Float)
fun get(key: String): String?
fun getBoolean(key: String): Boolean
fun getFloat(key: String): Float
fun getInt(key: String): Int
fun getLong(key: String): Long
fun clear(key: String)
fun clearAllFromServer(server: String)
companion object {
const val KEY_PUSH_TOKEN = "KEY_PUSH_TOKEN"
const val MIGRATION_FINISHED_KEY = "MIGRATION_FINISHED_KEY"
const val TOKEN_KEY = "token_"
const val SETTINGS_KEY = "settings_"
const val USERNAME_KEY = "my_username"
const val UNFINISHED_MSG_KEY = "unfinished_msg_"
const val CURRENT_USERNAME_KEY = "username_"
}
}
fun save(key: String, value: String?)
fun get(key: String): String?
fun clear(key: String)
fun clearAllFromServer(server: String)
}
\ No newline at end of file
fun LocalRepository.checkIfMyself(username: String) = get(LocalRepository.CURRENT_USERNAME_KEY) == username
\ No newline at end of file
......@@ -3,23 +3,32 @@ package chat.rocket.android.infrastructure
import android.content.SharedPreferences
class SharedPrefsLocalRepository(private val preferences: SharedPreferences) : LocalRepository {
override fun getBoolean(key: String) = preferences.getBoolean(key, false)
override fun save(key: String, value: String?) {
preferences.edit().putString(key, value).apply()
}
override fun getFloat(key: String) = preferences.getFloat(key, -1f)
override fun get(key: String): String? {
return preferences.getString(key, null)
}
override fun getInt(key: String) = preferences.getInt(key, -1)
override fun clear(key: String) {
preferences.edit().remove(key).apply()
}
override fun getLong(key: String) = preferences.getLong(key, -1L)
override fun save(key: String, value: Int) = preferences.edit().putInt(key, value).apply()
override fun save(key: String, value: Float) = preferences.edit().putFloat(key, value).apply()
override fun save(key: String, value: Long) = preferences.edit().putLong(key, value).apply()
override fun save(key: String, value: Boolean) = preferences.edit().putBoolean(key, value).apply()
override fun save(key: String, value: String?) = preferences.edit().putString(key, value).apply()
override fun get(key: String): String? = preferences.getString(key, null)
override fun clear(key: String) = preferences.edit().remove(key).apply()
override fun clearAllFromServer(server: String) {
clear(LocalRepository.KEY_PUSH_TOKEN)
clear(LocalRepository.TOKEN_KEY + server)
clear(LocalRepository.SETTINGS_KEY + server)
clear(LocalRepository.USERNAME_KEY + server)
clear(LocalRepository.CURRENT_USERNAME_KEY)
}
}
\ No newline at end of file
package chat.rocket.android.main.adapter
import android.support.v7.widget.RecyclerView
import android.view.View
import chat.rocket.android.server.domain.model.Account
import kotlinx.android.synthetic.main.item_account.view.*
class AccountViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(account: Account) {
with(itemView) {
server_logo.setImageURI(account.serverLogo)
text_server_url.text = account.serverUrl
text_username.text = account.userName
}
}
}
\ No newline at end of file
package chat.rocket.android.main.adapter
import android.support.v7.widget.RecyclerView
import android.view.ViewGroup
import chat.rocket.android.R
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.util.extensions.inflate
class AccountsAdapter(
private val accounts: List<Account>,
private val selector: AccountSelector
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_ACCOUNT -> AccountViewHolder(parent.inflate(R.layout.item_account))
else -> AddAccountViewHolder(parent.inflate(R.layout.item_add_account))
}
}
override fun getItemCount() = accounts.size + 1
override fun getItemViewType(position: Int) =
if (position == accounts.size) VIEW_TYPE_ADD_ACCOUNT else VIEW_TYPE_ACCOUNT
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is AccountViewHolder -> bindAccountViewHolder(holder, position)
is AddAccountViewHolder -> bindAddAccountViewHolder(holder, position)
}
}
private fun bindAccountViewHolder(holder: AccountViewHolder, position: Int) {
val account = accounts[position]
holder.bind(account)
holder.itemView.setOnClickListener {
selector.onAccountSelected(account.serverUrl)
}
}
private fun bindAddAccountViewHolder(holder: AddAccountViewHolder, position: Int) {
holder.itemView.setOnClickListener {
selector.onAddedAccountSelected()
}
}
}
interface AccountSelector {
fun onAccountSelected(serverUrl: String)
fun onAddedAccountSelected()
}
private const val VIEW_TYPE_ACCOUNT = 0
private const val VIEW_TYPE_ADD_ACCOUNT = 1
\ No newline at end of file
package chat.rocket.android.main.adapter
import android.support.v7.widget.RecyclerView
import android.view.View
class AddAccountViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
\ No newline at end of file
......@@ -16,7 +16,7 @@ class MainModule {
@Provides
@PerActivity
fun provideMainNavigator(activity: MainActivity, context: Context) = MainNavigator(activity, context)
fun provideMainNavigator(activity: MainActivity) = MainNavigator(activity)
@Provides
fun provideMainView(activity: MainActivity): MainView = activity
......
......@@ -2,14 +2,16 @@ package chat.rocket.android.main.presentation
import android.content.Context
import chat.rocket.android.R
import chat.rocket.android.authentication.ui.newServerIntent
import chat.rocket.android.chatroom.ui.chatRoomIntent
import chat.rocket.android.chatrooms.ui.ChatRoomsFragment
import chat.rocket.android.main.ui.MainActivity
import chat.rocket.android.profile.ui.ProfileFragment
import chat.rocket.android.server.ui.changeServerIntent
import chat.rocket.android.settings.ui.SettingsFragment
import chat.rocket.android.util.extensions.addFragment
class MainNavigator(internal val activity: MainActivity, internal val context: Context) {
class MainNavigator(internal val activity: MainActivity) {
fun toChatList() {
activity.addFragment("ChatRoomsFragment", R.id.fragment_container) {
......@@ -35,8 +37,17 @@ class MainNavigator(internal val activity: MainActivity, internal val context: C
isChatRoomReadOnly: Boolean,
chatRoomLastSeen: Long,
isChatRoomSubscribed: Boolean) {
activity.startActivity(context.chatRoomIntent(chatRoomId, chatRoomName, chatRoomType,
activity.startActivity(activity.chatRoomIntent(chatRoomId, chatRoomName, chatRoomType,
isChatRoomReadOnly, chatRoomLastSeen, isChatRoomSubscribed))
activity.overridePendingTransition(R.anim.open_enter, R.anim.open_exit)
}
fun toNewServer(serverUrl: String? = null) {
activity.startActivity(activity.changeServerIntent(serverUrl))
activity.finish()
}
fun toServerScreen() {
activity.startActivity(activity.newServerIntent())
}
}
\ No newline at end of file
package chat.rocket.android.main.presentation
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.main.viewmodel.NavHeaderViewModel
import chat.rocket.android.main.viewmodel.NavHeaderViewModelMapper
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.server.presentation.CheckServerPresenter
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.android.util.extensions.registerPushToken
import chat.rocket.common.RocketChatAuthException
import chat.rocket.common.RocketChatException
import chat.rocket.common.util.ifNull
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.realtime.disconnect
import chat.rocket.core.internal.rest.logout
import chat.rocket.core.internal.rest.me
import chat.rocket.core.internal.rest.unregisterPushToken
import timber.log.Timber
import javax.inject.Inject
class MainPresenter @Inject constructor(private val view: MainView,
private val strategy: CancelStrategy,
private val navigator: MainNavigator,
private val serverInteractor: GetCurrentServerInteractor,
private val localRepository: LocalRepository,
managerFactory: ConnectionManagerFactory,
factory: RocketChatClientFactory) {
class MainPresenter @Inject constructor(
private val view: MainView,
private val strategy: CancelStrategy,
private val navigator: MainNavigator,
private val tokenRepository: TokenRepository,
private val serverInteractor: GetCurrentServerInteractor,
private val localRepository: LocalRepository,
private val navHeaderMapper: NavHeaderViewModelMapper,
private val saveAccountInteractor: SaveAccountInteractor,
private val getAccountsInteractor: GetAccountsInteractor,
private val removeAccountInterector: RemoveAccountInterector,
private val factory: RocketChatClientFactory,
getSettingsInteractor: GetSettingsInteractor,
managerFactory: ConnectionManagerFactory
) : CheckServerPresenter(strategy, client = factory.create(serverInteractor.get()!!), view = view) {
private val currentServer = serverInteractor.get()!!
private val manager = managerFactory.create(currentServer)
private val client: RocketChatClient = factory.create(currentServer)
private var settings: PublicSettings = getSettingsInteractor.get(serverInteractor.get()!!)
fun toChatList() = navigator.toChatList()
......@@ -31,6 +48,41 @@ class MainPresenter @Inject constructor(private val view: MainView,
fun toSettings() = navigator.toSettings()
fun loadCurrentInfo() {
checkServerInfo()
launchUI(strategy) {
try {
val me = client.me()
val model = navHeaderMapper.mapToViewModel(me)
saveAccount(model)
view.setupNavHeader(model, getAccountsInteractor.get())
} catch (ex: Exception) {
when (ex) {
is RocketChatAuthException -> {
logout()
}
else -> {
Timber.d(ex, "Error loading my information for navheader")
ex.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
}
}
}
}
}
private suspend fun saveAccount(me: NavHeaderViewModel) {
val icon = settings.favicon()?.let {
UrlHelper.getServerLogoUrl(currentServer, it)
}
val account = Account(currentServer, icon, me.serverLogo, me.username, me.avatar)
saveAccountInteractor.save(account)
}
/**
* Logout from current server.
*/
......@@ -39,16 +91,25 @@ class MainPresenter @Inject constructor(private val view: MainView,
try {
clearTokens()
client.logout()
//TODO: Add the code to unsubscribe to all subscriptions.
client.disconnect()
view.onLogout()
} catch (exception: RocketChatException) {
Timber.d(exception, "Error calling logout")
exception.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
}
try {
disconnect()
removeAccountInterector.remove(currentServer)
tokenRepository.remove(currentServer)
navigator.toNewServer()
} catch (ex: Exception) {
Timber.d(ex, "Error cleaning up the session...")
}
navigator.toNewServer()
}
}
......@@ -57,7 +118,6 @@ class MainPresenter @Inject constructor(private val view: MainView,
val pushToken = localRepository.get(LocalRepository.KEY_PUSH_TOKEN)
if (pushToken != null) {
client.unregisterPushToken(pushToken)
localRepository.clear(LocalRepository.KEY_PUSH_TOKEN)
}
localRepository.clearAllFromServer(currentServer)
}
......@@ -69,4 +129,22 @@ class MainPresenter @Inject constructor(private val view: MainView,
fun disconnect() {
manager.disconnect()
}
fun changeServer(serverUrl: String) {
if (currentServer != serverUrl) {
navigator.toNewServer(serverUrl)
} else {
view.closeServerSelection()
}
}
fun addNewServer() {
navigator.toServerScreen()
}
suspend fun refreshToken(token: String?) {
token?.let {
client.registerPushToken(it, getAccountsInteractor.get(), factory)
}
}
}
\ No newline at end of file
package chat.rocket.android.main.presentation
import chat.rocket.android.authentication.server.presentation.VersionCheckView
import chat.rocket.android.core.behaviours.MessageView
import chat.rocket.android.main.viewmodel.NavHeaderViewModel
import chat.rocket.android.server.domain.model.Account
interface MainView : MessageView {
/**
* User has successfully logged out from the current server.
**/
fun onLogout()
interface MainView : MessageView, VersionCheckView {
fun setupNavHeader(model: NavHeaderViewModel, accounts: List<Account>)
fun closeServerSelection()
}
\ No newline at end of file
package chat.rocket.android.main.ui
import android.app.Activity
import android.content.Intent
import android.app.AlertDialog
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.LinearLayoutManager
import android.view.Gravity
import android.view.MenuItem
import android.view.View
import chat.rocket.android.BuildConfig
import chat.rocket.android.R
import chat.rocket.android.authentication.ui.AuthenticationActivity
import chat.rocket.android.main.adapter.AccountSelector
import chat.rocket.android.main.adapter.AccountsAdapter
import chat.rocket.android.main.presentation.MainPresenter
import chat.rocket.android.main.presentation.MainView
import chat.rocket.android.main.viewmodel.NavHeaderViewModel
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.util.extensions.fadeIn
import chat.rocket.android.util.extensions.fadeOut
import chat.rocket.android.util.extensions.rotateBy
import chat.rocket.android.util.extensions.showToast
import com.google.android.gms.gcm.GoogleCloudMessaging
import com.google.android.gms.iid.InstanceID
import dagger.android.AndroidInjection
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
......@@ -19,6 +30,10 @@ import dagger.android.HasActivityInjector
import dagger.android.support.HasSupportFragmentInjector
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.app_bar.*
import kotlinx.android.synthetic.main.nav_header.view.*
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.launch
import timber.log.Timber
import javax.inject.Inject
class MainActivity : AppCompatActivity(), MainView, HasActivityInjector, HasSupportFragmentInjector {
......@@ -26,13 +41,21 @@ class MainActivity : AppCompatActivity(), MainView, HasActivityInjector, HasSupp
@Inject lateinit var fragmentDispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
@Inject lateinit var presenter: MainPresenter
private var isFragmentAdded: Boolean = false
private var expanded = false
override fun onCreate(savedInstanceState: Bundle?) {
AndroidInjection.inject(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
launch(CommonPool) {
val token = InstanceID.getInstance(this@MainActivity).getToken(getString(R.string.gcm_sender_id), GoogleCloudMessaging.INSTANCE_ID_SCOPE, null)
Timber.d("GCM token: $token")
presenter.refreshToken(token)
}
presenter.connect()
presenter.loadCurrentInfo()
setupToolbar()
setupNavigationView()
}
......@@ -52,11 +75,66 @@ class MainActivity : AppCompatActivity(), MainView, HasActivityInjector, HasSupp
}
}
override fun onLogout() {
finish()
val intent = Intent(this, AuthenticationActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
override fun setupNavHeader(model: NavHeaderViewModel, accounts: List<Account>) {
Timber.d("Setting up nav header: $model")
val headerLayout = view_navigation.getHeaderView(0)
headerLayout.text_name.text = model.username
headerLayout.text_server.text = model.server
headerLayout.image_avatar.setImageURI(model.avatar)
headerLayout.server_logo.setImageURI(model.serverLogo)
setupAccountsList(headerLayout, accounts)
}
override fun closeServerSelection() {
view_navigation.getHeaderView(0).account_container.performClick()
}
override fun alertNotRecommendedVersion() {
AlertDialog.Builder(this)
.setMessage(getString(R.string.msg_ver_not_recommended, BuildConfig.RECOMMENDED_SERVER_VERSION))
.setPositiveButton(R.string.msg_ok, null)
.create()
.show()
}
override fun blockAndAlertNotRequiredVersion() {
AlertDialog.Builder(this)
.setMessage(getString(R.string.msg_ver_not_minimum, BuildConfig.REQUIRED_SERVER_VERSION))
.setOnDismissListener { presenter.logout() }
.setPositiveButton(R.string.msg_ok, null)
.create()
.show()
}
private fun setupAccountsList(header: View, accounts: List<Account>) {
accounts_list.layoutManager = LinearLayoutManager(this)
accounts_list.adapter = AccountsAdapter(accounts, object : AccountSelector {
override fun onAccountSelected(serverUrl: String) {
presenter.changeServer(serverUrl)
}
override fun onAddedAccountSelected() {
presenter.addNewServer()
}
})
header.account_container.setOnClickListener {
header.account_expand.rotateBy(180f)
if (expanded) {
accounts_list.fadeOut()
} else {
accounts_list.fadeIn()
}
expanded = !expanded
}
header.image_avatar.setOnClickListener {
view_navigation.menu.findItem(R.id.action_profile).isChecked = true
presenter.toUserProfile()
drawer_layout.closeDrawer(Gravity.START)
}
}
override fun showMessage(resId: Int) = showToast(resId)
......
package chat.rocket.android.main.viewmodel
data class NavHeaderViewModel(
val username: String,
val server: String,
val avatar: String?,
val serverLogo: String?
)
\ No newline at end of file
package chat.rocket.android.main.viewmodel
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.server.domain.*
import chat.rocket.core.model.Myself
import javax.inject.Inject
class NavHeaderViewModelMapper @Inject constructor(serverInteractor: GetCurrentServerInteractor,
getSettingsInteractor: GetSettingsInteractor) {
private val currentServer = serverInteractor.get()!!
private var settings: PublicSettings = getSettingsInteractor.get(currentServer)
fun mapToViewModel(me: Myself): NavHeaderViewModel {
val username = mapUsername(me)
val thumb = me.username?.let { UrlHelper.getAvatarUrl(currentServer, it) }
val image = settings.wideTile() ?: settings.faviconLarge()
val logo = image?.let { UrlHelper.getServerLogoUrl(currentServer, it) }
return NavHeaderViewModel(username, currentServer, thumb, logo)
}
private fun mapUsername(me: Myself): String {
val username = me.username
val realName = me.name
val senderName = if (settings.useRealName()) realName else username
return senderName ?: username.toString()
}
}
\ No newline at end of file
......@@ -6,6 +6,7 @@ import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import chat.rocket.android.R
......@@ -83,16 +84,31 @@ class MembersFragment : Fragment(), MembersView {
} else {
adapter.appendData(dataSet)
}
if (this is ChatRoomActivity) {
this.showRoomTypeIcon(false)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
(activity as ChatRoomActivity).showRoomTypeIcon(true)
return super.onOptionsItemSelected(item)
}
return super.onOptionsItemSelected(item)
}
override fun showLoading() = view_loading.setVisible(true)
override fun hideLoading() = view_loading.setVisible(false)
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int) {
showToast(resId)
}
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
......
......@@ -56,8 +56,8 @@ class ProfileFragment : Fragment(), ProfileView, ActionMode.Callback {
text_email.textContent = email
text_avatar_url.textContent = ""
currentName = username
currentUsername = name
currentName = name
currentUsername = username
currentEmail = email
currentAvatar = avatarUrl
......@@ -66,7 +66,9 @@ class ProfileFragment : Fragment(), ProfileView, ActionMode.Callback {
listenToChanges()
}
override fun showProfileUpdateSuccessfullyMessage() = showMessage(getString(R.string.msg_profile_update_successfully))
override fun showProfileUpdateSuccessfullyMessage() {
showMessage(getString(R.string.msg_profile_update_successfully))
}
override fun showLoading() {
enableUserInput(false)
......@@ -74,13 +76,19 @@ class ProfileFragment : Fragment(), ProfileView, ActionMode.Callback {
}
override fun hideLoading() {
view_loading.setVisible(false)
if (view_loading != null) {
view_loading.setVisible(false)
}
enableUserInput(true)
}
override fun showMessage(resId: Int) = showToast(resId)
override fun showMessage(resId: Int) {
showToast(resId)
}
override fun showMessage(message: String) = showToast(message)
override fun showMessage(message: String) {
showToast(message)
}
override fun showGenericErrorMessage() = showMessage(getString(R.string.msg_generic_error))
......
package chat.rocket.android.push
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.android.AndroidInjection
import javax.inject.Inject
/**
* BroadcastReceiver for dismissed notifications.
*/
class DeleteReceiver : BroadcastReceiver() {
@Inject
lateinit var groupedPushes: GroupedPush
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val notId = intent.extras?.getInt(EXTRA_NOT_ID)
val host = intent.extras?.getString(EXTRA_HOSTNAME)
if (host != null && notId != null) {
clearNotificationsByHostAndNotificationId(host, notId)
}
}
/**
* Clear notifications by the host they belong to and its unique id.
*/
fun clearNotificationsByHostAndNotificationId(host: String, notificationId: Int) {
if (groupedPushes.hostToPushMessageList.isNotEmpty()) {
val notifications = groupedPushes.hostToPushMessageList[host]
notifications?.let {
notifications.removeAll {
it.notificationId.toInt() == notificationId
}
}
}
}
}
\ No newline at end of file
......@@ -2,6 +2,8 @@ package chat.rocket.android.push
import chat.rocket.android.R
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.common.RocketChatException
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.registerPushToken
......@@ -16,14 +18,16 @@ import javax.inject.Inject
class FirebaseTokenService : FirebaseInstanceIdService() {
@Inject
lateinit var client: RocketChatClient
lateinit var factory: RocketChatClientFactory
@Inject
lateinit var getCurrentServerInteractor: GetCurrentServerInteractor
@Inject
lateinit var localRepository: LocalRepository
override fun onCreate() {
super.onCreate()
AndroidInjection.inject(this);
AndroidInjection.inject(this)
}
override fun onTokenRefresh() {
......@@ -31,11 +35,14 @@ class FirebaseTokenService : FirebaseInstanceIdService() {
// default push gateway. We should register this project's own project sender id into it.
val gcmToken = InstanceID.getInstance(this)
.getToken(getString(R.string.gcm_sender_id), GoogleCloudMessaging.INSTANCE_ID_SCOPE, null)
val currentServer = getCurrentServerInteractor.get()!!
val client = factory.create(currentServer)
gcmToken?.let {
localRepository.save(LocalRepository.KEY_PUSH_TOKEN, gcmToken)
launch {
try {
Timber.d("Registering push token: $gcmToken for ${client.url}")
client.registerPushToken(gcmToken)
} catch (ex: RocketChatException) {
Timber.e(ex)
......
......@@ -2,12 +2,22 @@ package chat.rocket.android.push
import android.os.Bundle
import com.google.android.gms.gcm.GcmListenerService
import dagger.android.AndroidInjection
import javax.inject.Inject
class GcmListenerService : GcmListenerService() {
@Inject
lateinit var pushManager: PushManager
override fun onCreate() {
super.onCreate()
AndroidInjection.inject(this)
}
override fun onMessageReceived(from: String?, data: Bundle?) {
data?.let {
PushManager.handle(this, data)
pushManager.handle(data)
}
}
}
\ No newline at end of file
package chat.rocket.android.push
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Singleton
typealias TupleGroupIdMessageCount = Pair<Int, AtomicInteger>
class GroupedPush {
// Notifications received from the same server are grouped in a single bundled notification.
// This map associates a host to a group id.
val groupMap = HashMap<String, TupleGroupIdMessageCount>()
// Map a hostname to a list of push messages that pertain to it.
val hostToPushMessageList = HashMap<String, MutableList<PushMessage>>()
}
\ No newline at end of file
......@@ -5,7 +5,6 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
......@@ -17,36 +16,34 @@ import android.support.v4.app.NotificationManagerCompat
import android.support.v4.app.RemoteInput
import android.text.Html
import android.text.Spanned
import android.util.Log
import chat.rocket.android.BuildConfig
import chat.rocket.android.R
import chat.rocket.android.main.ui.MainActivity
import org.json.JSONObject
import java.io.Serializable
import chat.rocket.android.server.domain.GetAccountInteractor
import chat.rocket.android.server.domain.GetSettingsInteractor
import chat.rocket.android.server.domain.siteName
import chat.rocket.android.server.ui.changeServerIntent
import chat.rocket.common.model.RoomType
import com.squareup.moshi.Json
import com.squareup.moshi.Moshi
import kotlinx.coroutines.experimental.runBlocking
import se.ansman.kotshi.JsonSerializable
import timber.log.Timber
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import kotlin.collections.HashMap
typealias TupleGroupIdMessageCount = Pair<Int, AtomicInteger>
import javax.inject.Inject
/**
* Refer to: https://github.com/RocketChat/Rocket.Chat.Android/blob/9e846b7fde8fe0c74b9e0117c37ce49293308db5/app/src/main/java/chat/rocket/android/push/PushManager.kt
* for old source code.
*/
object PushManager {
const val EXTRA_NOT_ID = "chat.rocket.android.EXTRA_NOT_ID"
const val EXTRA_HOSTNAME = "chat.rocket.android.EXTRA_HOSTNAME"
const val EXTRA_PUSH_MESSAGE = "chat.rocket.android.EXTRA_PUSH_MESSAGE"
const val EXTRA_ROOM_ID = "chat.rocket.android.EXTRA_ROOM_ID"
private const val REPLY_LABEL = "REPLY"
private const val REMOTE_INPUT_REPLY = "REMOTE_INPUT_REPLY"
// Notifications received from the same server are grouped in a single bundled notification.
// This map associates a host to a group id.
private val groupMap = HashMap<String, TupleGroupIdMessageCount>()
// Map a hostname to a list of push messages that pertain to it.
private val hostToPushMessageList = HashMap<String, MutableList<PushMessage>>()
class PushManager @Inject constructor(
private val groupedPushes: GroupedPush,
private val manager: NotificationManager,
private val moshi: Moshi,
private val getAccountInteractor: GetAccountInteractor,
private val getSettingsInteractor: GetSettingsInteractor,
private val context: Context
) {
private val randomizer = Random()
/**
......@@ -54,241 +51,92 @@ object PushManager {
* on the *data* param bundle received.
*/
@Synchronized
fun handle(context: Context, data: Bundle) {
val appContext = context.applicationContext
fun handle(data: Bundle) = runBlocking {
val message = data["message"] as String?
val image = data["image"] as String?
val ejson = data["ejson"] as String?
val title = data["title"] as String?
val notId = data["notId"] as String? ?: randomizer.nextInt().toString()
val image = data["image"] as String?
val style = data["style"] as String?
val summaryText = data["summaryText"] as String?
val count = data["count"] as String?
val title = data["title"] as String?
if (ejson == null || message == null || title == null) {
return
}
try {
val adapter = moshi.adapter<PushInfo>(PushInfo::class.java)
val info = adapter.fromJson(ejson)
val lastPushMessage = PushMessage(title, message, image, ejson, count, notId, summaryText, style)
val pushMessage = PushMessage(title!!, message!!, info!!, image, count, notId, summaryText, style)
// We should use Timber here
if (BuildConfig.DEBUG) {
Log.d(PushMessage::class.java.simpleName, lastPushMessage.toString())
}
showNotification(appContext, lastPushMessage)
}
/**
* Clear all messages received to a given host the user is signed-in.
*/
fun clearNotificationsByHost(host: String) {
hostToPushMessageList.remove(host)
}
Timber.d("Received push message: $pushMessage")
/**
* Remove a notification solely by it's unique id.
*/
fun clearNotificationsByNotificationId(notificationId: Int) {
if (hostToPushMessageList.isNotEmpty()) {
for (entry in hostToPushMessageList.entries) {
entry.value.removeAll {
it.notificationId.toInt() == notificationId
}
}
showNotification(pushMessage)
} catch (ex: Exception) {
Timber.d(ex, "Error parsing PUSH message: $data")
ex.printStackTrace()
}
}
/**
* Clear notifications by the host they belong to and its unique id.
*/
fun clearNotificationsByHostAndNotificationId(host: String?, notificationId: Int?) {
if (host == null || notificationId == null) {
return
}
if (hostToPushMessageList.isNotEmpty()) {
val notifications = hostToPushMessageList[host]
notifications?.let {
notifications.removeAll {
it.notificationId.toInt() == notificationId
}
}
}
}
private fun getGroupForHost(host: String): TupleGroupIdMessageCount {
val size = groupMap.size
var group = groupMap.get(host)
if (group == null) {
group = TupleGroupIdMessageCount(size + 1, AtomicInteger(0))
groupMap.put(host, group)
}
return group
}
@SuppressLint("NewApi")
internal fun showNotification(context: Context, lastPushMessage: PushMessage) {
if (lastPushMessage.host == null || lastPushMessage.message == null || lastPushMessage.title == null) {
private suspend fun showNotification(pushMessage: PushMessage) {
if (!hasAccount(pushMessage.info.host)) {
Timber.d("ignoring push message: $pushMessage")
return
}
val manager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notId = lastPushMessage.notificationId.toInt()
val host = lastPushMessage.host
val notId = pushMessage.notificationId.toInt()
val host = pushMessage.info.host
val groupTuple = getGroupForHost(host)
groupTuple.second.incrementAndGet()
val notIdListForHostname: MutableList<PushMessage>? = hostToPushMessageList.get(host)
val notIdListForHostname: MutableList<PushMessage>? = groupedPushes.hostToPushMessageList.get(host)
if (notIdListForHostname == null) {
hostToPushMessageList.put(host, arrayListOf(lastPushMessage))
groupedPushes.hostToPushMessageList[host] = arrayListOf(pushMessage)
} else {
notIdListForHostname.add(0, lastPushMessage)
notIdListForHostname.add(0, pushMessage)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val notification = createSingleNotificationForNougatAndAbove(context, lastPushMessage)
val groupNotification = createGroupNotificationForNougatAndAbove(context, lastPushMessage)
notification?.let {
manager.notify(notId, notification)
}
groupNotification?.let {
manager.notify(groupTuple.first, groupNotification)
}
} else {
val notification = createSingleNotification(context, lastPushMessage)
val pushMessageList = hostToPushMessageList.get(host)
val notification = createSingleNotification(pushMessage)
val pushMessageList = groupedPushes.hostToPushMessageList[host]
notification?.let {
NotificationManagerCompat.from(context).notify(notId, notification)
}
notification?.let {
manager.notify(notId, notification)
}
pushMessageList?.let {
if (pushMessageList.size > 1) {
val groupNotification = createGroupNotification(context, lastPushMessage)
groupNotification?.let {
NotificationManagerCompat.from(context).notify(groupTuple.first, groupNotification)
}
pushMessageList?.let {
if (pushMessageList.size > 1) {
val groupNotification = createGroupNotification(pushMessage)
groupNotification?.let {
NotificationManagerCompat.from(context).notify(groupTuple.first, groupNotification)
}
}
}
}
internal fun createGroupNotification(context: Context, lastPushMessage: PushMessage): Notification? {
with(lastPushMessage) {
if (host == null || message == null || title == null) {
return null
}
val id = lastPushMessage.notificationId.toInt()
val contentIntent = getContentIntent(context, id, lastPushMessage)
val deleteIntent = getDismissIntent(context, lastPushMessage)
val builder = NotificationCompat.Builder(context)
.setWhen(createdAt)
.setContentTitle(title.fromHtml())
.setContentText(message.fromHtml())
.setGroup(host)
.setGroupSummary(true)
.setContentIntent(contentIntent)
.setDeleteIntent(deleteIntent)
.setMessageNotification()
//TODO: Get Site_Name PublicSetting from cache
val subText = "Rocket.Chat"
if (subText.isNotEmpty()) {
builder.setSubText(subText)
}
if (style == null || style == "inbox") {
val pushMessageList = hostToPushMessageList.get(host)
pushMessageList?.let {
val messageCount = pushMessageList.size
val summary = summaryText?.replace("%n%", messageCount.toString())
?.fromHtml() ?: "$messageCount new messages"
builder.setNumber(messageCount)
if (messageCount > 1) {
val firstPush = pushMessageList[0]
val singleConversation = pushMessageList.filter {
firstPush.sender?.username != it.sender?.username
}.isEmpty()
val inbox = NotificationCompat.InboxStyle()
.setBigContentTitle(if (singleConversation) title else summary)
for (push in pushMessageList) {
if (singleConversation) {
inbox.addLine(push.message)
} else {
inbox.addLine("<font color='black'>${push.title}</font> <font color='gray'>${push.message}</font>".fromHtml())
}
}
builder.setStyle(inbox)
} else {
val firstMsg = pushMessageList[0]
if (firstMsg.host == null || firstMsg.message == null || firstMsg.title == null) {
return null
}
val bigText = NotificationCompat.BigTextStyle()
.bigText(firstMsg.message.fromHtml())
.setBigContentTitle(firstMsg.title.fromHtml())
builder.setStyle(bigText)
}
}
} else {
val bigText = NotificationCompat.BigTextStyle()
.bigText(message.fromHtml())
.setBigContentTitle(title.fromHtml())
builder.setStyle(bigText)
}
return builder.build()
private fun getGroupForHost(host: String): TupleGroupIdMessageCount {
val size = groupedPushes.groupMap.size
var group = groupedPushes.groupMap[host]
if (group == null) {
group = TupleGroupIdMessageCount(size + 1, AtomicInteger(0))
groupedPushes.groupMap[host] = group
}
return group
}
private suspend fun hasAccount(host: String): Boolean {
return getAccountInteractor.get(host) != null
}
@SuppressLint("NewApi")
@RequiresApi(Build.VERSION_CODES.N)
internal fun createGroupNotificationForNougatAndAbove(context: Context, lastPushMessage: PushMessage): Notification? {
with(lastPushMessage) {
if (host == null || message == null || title == null) {
return null
}
val manager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val id = notificationId.toInt()
val contentIntent = getContentIntent(context, id, lastPushMessage, grouped = true)
val deleteIntent = getDismissIntent(context, lastPushMessage)
private fun createGroupNotification(pushMessage: PushMessage): Notification? {
with(pushMessage) {
val host = info.host
val builder = Notification.Builder(context)
.setWhen(createdAt)
.setContentTitle(title.fromHtml())
.setContentText(message.fromHtml())
.setGroup(host)
val builder = createBaseNotificationBuilder(pushMessage, grouped = true)
.setGroupSummary(true)
.setContentIntent(contentIntent)
.setDeleteIntent(deleteIntent)
.setMessageNotification(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setChannelId(host)
val groupChannel = NotificationChannel(host, host, NotificationManager.IMPORTANCE_HIGH)
groupChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
groupChannel.enableLights(false)
groupChannel.enableVibration(true)
groupChannel.setShowBadge(true)
manager.createNotificationChannel(groupChannel)
}
//TODO: Get Site_Name PublicSetting from cache
val subText = "Rocket.Chat"
if (subText.isNotEmpty()) {
builder.setSubText(subText)
}
if (style == null || style == "inbox") {
val pushMessageList = hostToPushMessageList.get(host)
val pushMessageList = groupedPushes.hostToPushMessageList[host]
pushMessageList?.let {
val count = pushMessageList.filter {
......@@ -297,7 +145,7 @@ object PushManager {
builder.setContentTitle(getTitle(count, title))
val inbox = Notification.InboxStyle()
val inbox = NotificationCompat.InboxStyle()
.setBigContentTitle(getTitle(count, title))
for (push in pushMessageList) {
......@@ -307,7 +155,7 @@ object PushManager {
builder.setStyle(inbox)
}
} else {
val bigText = Notification.BigTextStyle()
val bigText = NotificationCompat.BigTextStyle()
.bigText(message.fromHtml())
.setBigContentTitle(title.fromHtml())
......@@ -318,99 +166,21 @@ object PushManager {
}
}
internal fun createSingleNotification(context: Context, lastPushMessage: PushMessage): Notification? {
with(lastPushMessage) {
if (host == null || message == null || title == null) {
return null
}
val id = notificationId.toInt()
val contentIntent = getContentIntent(context, id, lastPushMessage)
val deleteIntent = getDismissIntent(context, lastPushMessage)
val builder = NotificationCompat.Builder(context)
.setWhen(createdAt)
.setContentTitle(title.fromHtml())
.setContentText(message.fromHtml())
.setGroupSummary(false)
.setGroup(host)
.setDeleteIntent(deleteIntent)
.setContentIntent(contentIntent)
.setMessageNotification()
//TODO: Get Site_Name PublicSetting from cache
val subText = "Rocket.Chat"
if (subText.isNotEmpty()) {
builder.setSubText(subText)
}
val pushMessageList = hostToPushMessageList.get(host)
pushMessageList?.let {
val lastPushMsg = pushMessageList.last()
if (lastPushMsg.host == null || lastPushMsg.message == null || lastPushMsg.title == null) {
return null
}
if (pushMessageList.isNotEmpty()) {
val messageCount = pushMessageList.size
val bigText = NotificationCompat.BigTextStyle()
.bigText(lastPushMsg.message.fromHtml())
.setBigContentTitle(lastPushMsg.title.fromHtml())
builder.setStyle(bigText).setNumber(messageCount)
}
}
return builder.build()
}
}
@SuppressLint("NewApi")
@RequiresApi(Build.VERSION_CODES.N)
internal fun createSingleNotificationForNougatAndAbove(context: Context, lastPushMessage: PushMessage): Notification? {
val manager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
with(lastPushMessage) {
if (host == null || message == null || title == null) {
return null
}
val id = notificationId.toInt()
val contentIntent = getContentIntent(context, id, lastPushMessage)
val deleteIntent = getDismissIntent(context, lastPushMessage)
private fun createSingleNotification(pushMessage: PushMessage): Notification? {
with(pushMessage) {
val host = info.host
val builder = Notification.Builder(context)
.setWhen(createdAt)
.setContentTitle(title.fromHtml())
.setContentText(message.fromHtml())
.setGroup(host)
val builder = createBaseNotificationBuilder(pushMessage)
.setGroupSummary(false)
.setDeleteIntent(deleteIntent)
.setContentIntent(contentIntent)
.setMessageNotification(context)
.addReplyAction(context, lastPushMessage)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setChannelId(host)
val channel = NotificationChannel(host, host, NotificationManager.IMPORTANCE_HIGH)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
channel.enableLights(false)
channel.enableVibration(true)
channel.setShowBadge(true)
manager.createNotificationChannel(channel)
}
//TODO: Get Site_Name PublicSetting from cache
val subText = "Rocket.Chat"
if (subText.isNotEmpty()) {
builder.setSubText(subText)
}
if (style == null || "inbox" == style) {
val pushMessageList = hostToPushMessageList.get(host)
val pushMessageList = groupedPushes.hostToPushMessageList.get(host)
pushMessageList?.let {
val userMessages = pushMessageList.filter {
it.notificationId == lastPushMessage.notificationId
it.notificationId == pushMessage.notificationId
}
val count = pushMessageList.filter {
......@@ -420,7 +190,7 @@ object PushManager {
builder.setContentTitle(getTitle(count, title))
if (count > 1) {
val inbox = Notification.InboxStyle()
val inbox = NotificationCompat.InboxStyle()
inbox.setBigContentTitle(getTitle(count, title))
for (push in userMessages) {
inbox.addLine(push.message)
......@@ -428,13 +198,13 @@ object PushManager {
builder.setStyle(inbox)
} else {
val bigTextStyle = Notification.BigTextStyle()
val bigTextStyle = NotificationCompat.BigTextStyle()
.bigText(message.fromHtml())
builder.setStyle(bigTextStyle)
}
}
} else {
val bigTextStyle = Notification.BigTextStyle()
val bigTextStyle = NotificationCompat.BigTextStyle()
.bigText(message.fromHtml())
builder.setStyle(bigTextStyle)
}
......@@ -443,6 +213,47 @@ object PushManager {
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createBaseNotificationBuilder(pushMessage: PushMessage, grouped: Boolean = false): NotificationCompat.Builder {
return with(pushMessage) {
val id = notificationId.toInt()
val host = info.host
val contentIntent = getContentIntent(context, id, pushMessage, grouped)
val deleteIntent = getDismissIntent(context, pushMessage)
val builder = NotificationCompat.Builder(context, host)
.setWhen(info.createdAt)
.setContentTitle(title.fromHtml())
.setContentText(message.fromHtml())
.setGroup(host)
.setDeleteIntent(deleteIntent)
.setContentIntent(contentIntent)
.setMessageNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(host, host, NotificationManager.IMPORTANCE_HIGH)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
channel.enableLights(false)
channel.enableVibration(true)
channel.setShowBadge(true)
manager.createNotificationChannel(channel)
}
//TODO: Get Site_Name PublicSetting from cache
val subText = getSiteName(host)
if (subText.isNotEmpty()) {
builder.setSubText(subText)
}
return@with builder
}
}
private fun getSiteName(host: String): String {
val settings = getSettingsInteractor.get(host)
return settings.siteName() ?: "Rocket.Chat"
}
private fun getTitle(messageCount: Int, title: String): CharSequence {
return if (messageCount > 1) "($messageCount) ${title.fromHtml()}" else title.fromHtml()
}
......@@ -450,18 +261,16 @@ object PushManager {
private fun getDismissIntent(context: Context, pushMessage: PushMessage): PendingIntent {
val deleteIntent = Intent(context, DeleteReceiver::class.java)
.putExtra(EXTRA_NOT_ID, pushMessage.notificationId.toInt())
.putExtra(EXTRA_HOSTNAME, pushMessage.host)
.putExtra(EXTRA_HOSTNAME, pushMessage.info.host)
return PendingIntent.getBroadcast(context, pushMessage.notificationId.toInt(), deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun getContentIntent(context: Context, notificationId: Int, pushMessage: PushMessage, grouped: Boolean = false): PendingIntent {
val notificationIntent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(EXTRA_NOT_ID, notificationId)
.putExtra(EXTRA_HOSTNAME, pushMessage.host)
if (!grouped) {
notificationIntent.putExtra(EXTRA_ROOM_ID, pushMessage.rid)
}
val notificationIntent = context.changeServerIntent(pushMessage.info.host)
// TODO - add support to go directly to the chatroom
/*if (!grouped) {
notificationIntent.putExtra(EXTRA_ROOM_ID, pushMessage.info.roomId)
}*/
return PendingIntent.getActivity(context, randomizer.nextInt(), notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
......@@ -472,18 +281,18 @@ object PushManager {
//Notification.Builder extensions
@RequiresApi(Build.VERSION_CODES.N)
private fun Notification.Builder.addReplyAction(ctx: Context, pushMessage: PushMessage): Notification.Builder {
private fun Notification.Builder.addReplyAction(pushMessage: PushMessage): Notification.Builder {
val replyRemoteInput = android.app.RemoteInput.Builder(REMOTE_INPUT_REPLY)
.setLabel(REPLY_LABEL)
.build()
//TODO: Implement this when we have sendMessage call
// val replyIntent = Intent(ctx, ReplyReceiver::class.java)
// val replyIntent = Intent(context, ReplyReceiver::class.java)
// replyIntent.putExtra(EXTRA_PUSH_MESSAGE, pushMessage as Serializable)
// val pendingIntent = PendingIntent.getBroadcast(
// ctx, randomizer.nextInt(), replyIntent, 0)
// context, randomizer.nextInt(), replyIntent, 0)
// val replyAction =
// Notification.Action.Builder(
// Icon.createWithResource(ctx, R.drawable.ic_reply), REPLY_LABEL, pendingIntent)
// Icon.createWithResource(context, R.drawable.ic_reply), REPLY_LABEL, pendingIntent)
// .addRemoteInput(replyRemoteInput)
// .setAllowGeneratedReplies(true)
// .build()
......@@ -491,25 +300,8 @@ object PushManager {
return this
}
@RequiresApi(Build.VERSION_CODES.N)
private fun Notification.Builder.setMessageNotification(ctx: Context): Notification.Builder {
val alarmSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val res = ctx.resources
val smallIcon = res.getIdentifier(
"rocket_chat_notification", "drawable", ctx.packageName)
with(this, {
setAutoCancel(true)
setShowWhen(true)
setColor(res.getColor(R.color.colorPrimary, ctx.theme))
setSmallIcon(smallIcon)
setSound(alarmSound)
})
return this
}
// NotificationCompat.Builder extensions
private fun NotificationCompat.Builder.addReplyAction(pushMessage: PushMessage): NotificationCompat.Builder {
val context = this.mContext
val replyRemoteInput = RemoteInput.Builder(REMOTE_INPUT_REPLY)
.setLabel(REPLY_LABEL)
.build()
......@@ -529,76 +321,66 @@ object PushManager {
private fun NotificationCompat.Builder.setMessageNotification(): NotificationCompat.Builder {
val alarmSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val ctx = this.mContext
val res = ctx.resources
val res = context.resources
val smallIcon = res.getIdentifier(
"rocket_chat_notification", "drawable", ctx.packageName)
"rocket_chat_notification", "drawable", context.packageName)
with(this, {
setAutoCancel(true)
setShowWhen(true)
color = ctx.resources.getColor(R.color.colorPrimary)
color = context.resources.getColor(R.color.colorPrimary)
setDefaults(Notification.DEFAULT_ALL)
setSmallIcon(smallIcon)
setSound(alarmSound)
})
return this
}
internal data class PushMessage(
val title: String? = null,
val message: String? = null,
val image: String? = null,
val ejson: String? = null,
val count: String? = null,
val notificationId: String,
val summaryText: String? = null,
val style: String? = null) : Serializable {
val host: String?
val rid: String?
val type: String?
val channelName: String?
val sender: Sender?
val createdAt: Long
init {
val json = if (ejson == null) JSONObject() else JSONObject(ejson)
host = json.optString("host", null)
rid = json.optString("rid", null)
type = json.optString("type", null)
channelName = json.optString("name", null)
val senderJson = json.optString("sender", null)
if (senderJson != null && senderJson != "null") {
sender = Sender(senderJson)
} else {
sender = null
}
createdAt = System.currentTimeMillis()
}
data class Sender(val sender: String) : Serializable {
val _id: String?
val username: String?
val name: String?
init {
val json = JSONObject(sender)
_id = json.optString("_id", null)
username = json.optString("username", null)
name = json.optString("name", null)
}
}
}
data class PushMessage(
val title: String,
val message: String,
val info: PushInfo,
val image: String? = null,
val count: String? = null,
val notificationId: String,
val summaryText: String? = null,
val style: String? = null
)
@JsonSerializable
data class PushInfo(
@Json(name = "host") val hostname: String,
@Json(name = "rid") val roomId: String,
val type: RoomType,
val name: String?,
val sender: PushSender?
) {
val createdAt: Long
get() = System.currentTimeMillis()
val host by lazy {
sanitizeUrl(hostname)
}
/**
* BroadcastReceiver for dismissed notifications.
*/
class DeleteReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val notId = intent?.extras?.getInt(EXTRA_NOT_ID)
val host = intent?.extras?.getString(EXTRA_HOSTNAME)
if (host != null && notId != null) {
clearNotificationsByHostAndNotificationId(host, notId)
}
private fun sanitizeUrl(baseUrl: String): String {
var url = baseUrl.trim()
while (url.endsWith('/')) {
url = url.dropLast(1)
}
return url
}
}
\ No newline at end of file
}
@JsonSerializable
data class PushSender(
@Json(name = "_id") val id: String,
val username: String?,
val name: String?
)
const val EXTRA_NOT_ID = "chat.rocket.android.EXTRA_NOT_ID"
const val EXTRA_HOSTNAME = "chat.rocket.android.EXTRA_HOSTNAME"
const val EXTRA_PUSH_MESSAGE = "chat.rocket.android.EXTRA_PUSH_MESSAGE"
const val EXTRA_ROOM_ID = "chat.rocket.android.EXTRA_ROOM_ID"
private const val REPLY_LABEL = "REPLY"
private const val REMOTE_INPUT_REPLY = "REMOTE_INPUT_REPLY"
package chat.rocket.android.push.di
import chat.rocket.android.dagger.module.AppModule
import chat.rocket.android.push.DeleteReceiver
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class DeleteReceiverProvider {
@ContributesAndroidInjector(modules = [AppModule::class])
abstract fun provideDeleteReceiver(): DeleteReceiver
}
\ No newline at end of file
package chat.rocket.android.push.di
import chat.rocket.android.dagger.module.AppModule
import chat.rocket.android.push.GcmListenerService
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module abstract class GcmListenerServiceProvider {
@ContributesAndroidInjector(modules = [AppModule::class])
abstract fun provideGcmListenerService(): GcmListenerService
}
\ No newline at end of file
package chat.rocket.android.server.di
import android.arch.lifecycle.LifecycleOwner
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.dagger.scope.PerActivity
import chat.rocket.android.server.presentation.ChangeServerNavigator
import chat.rocket.android.server.presentation.ChangeServerView
import chat.rocket.android.server.ui.ChangeServerActivity
import dagger.Module
import dagger.Provides
import kotlinx.coroutines.experimental.Job
@Module
class ChangeServerModule {
@Provides
@PerActivity
fun provideChangeServerNavigator(activity: ChangeServerActivity) = ChangeServerNavigator(activity)
@Provides
@PerActivity
fun ChangeServerView(activity: ChangeServerActivity): ChangeServerView {
return activity
}
@Provides
fun provideLifecycleOwner(activity: ChangeServerActivity): LifecycleOwner = activity
@Provides
fun provideCancelStrategy(owner: LifecycleOwner, jobs: Job): CancelStrategy = CancelStrategy(owner, jobs)
}
\ No newline at end of file
package chat.rocket.android.server.domain
import chat.rocket.android.server.domain.model.Account
interface AccountsRepository {
suspend fun save(account: Account)
suspend fun load(): List<Account>
suspend fun remove(serverUrl: String)
}
\ No newline at end of file
package chat.rocket.android.server.domain
import javax.inject.Inject
class GetAccountInteractor @Inject constructor(val repository: AccountsRepository) {
suspend fun get(url: String) = repository.load().firstOrNull { account ->
url == account.serverUrl
}
}
\ No newline at end of file
package chat.rocket.android.server.domain
import javax.inject.Inject
class GetAccountsInteractor @Inject constructor(val repository: AccountsRepository) {
suspend fun get() = repository.load()
}
\ No newline at end of file
package chat.rocket.android.server.domain;
import java.util.List;
import io.reactivex.Scheduler;
import io.reactivex.Single;
public class GetServersInteractor {
private final ServersRepository repository;
private final Scheduler executionScheduler;
public GetServersInteractor(ServersRepository repository, Scheduler executionScheduler) {
this.repository = repository;
this.executionScheduler = executionScheduler;
}
}
......@@ -17,11 +17,12 @@ class RefreshSettingsInteractor @Inject constructor(private val factory: RocketC
ACCOUNT_GOOGLE, ACCOUNT_FACEBOOK, ACCOUNT_GITHUB, ACCOUNT_LINKEDIN, ACCOUNT_METEOR,
ACCOUNT_TWITTER, ACCOUNT_WORDPRESS, ACCOUNT_GITLAB,
SITE_URL, SITE_NAME, FAVICON_512, USE_REALNAME, ALLOW_ROOM_NAME_SPECIAL_CHARS,
SITE_URL, SITE_NAME, FAVICON_512, FAVICON_196, USE_REALNAME, ALLOW_ROOM_NAME_SPECIAL_CHARS,
FAVORITE_ROOMS, UPLOAD_STORAGE_TYPE, UPLOAD_MAX_FILE_SIZE, UPLOAD_WHITELIST_MIMETYPES,
HIDE_USER_JOIN, HIDE_USER_LEAVE,
HIDE_TYPE_AU, HIDE_MUTE_UNMUTE, HIDE_TYPE_RU, ALLOW_MESSAGE_DELETING,
ALLOW_MESSAGE_EDITING, ALLOW_MESSAGE_PINNING, SHOW_DELETED_STATUS, SHOW_EDITED_STATUS)
ALLOW_MESSAGE_EDITING, ALLOW_MESSAGE_PINNING, SHOW_DELETED_STATUS, SHOW_EDITED_STATUS,
WIDE_TILE_310)
suspend fun refresh(server: String) {
withContext(CommonPool) {
......
package chat.rocket.android.server.domain
import javax.inject.Inject
class RemoveAccountInterector @Inject constructor(val repository: AccountsRepository) {
suspend fun remove(serverUrl: String) {
repository.remove(serverUrl)
}
}
\ No newline at end of file
package chat.rocket.android.server.domain
import chat.rocket.android.server.domain.model.Account
import javax.inject.Inject
class SaveAccountInteractor @Inject constructor(val repository: AccountsRepository) {
suspend fun save(account: Account) = repository.save(account)
}
\ No newline at end of file
package chat.rocket.android.server.domain;
public class SaveServerInteractor {
}
package chat.rocket.android.server.domain
import chat.rocket.android.server.domain.model.Server
import chat.rocket.android.server.infraestructure.ServerEntity
import io.reactivex.Completable
import io.reactivex.Single
interface ServersRepository {
val servers: Single<List<Server>>
fun saveServer(server: Server): Completable
fun updateServer(server: Server): Completable
}
\ No newline at end of file
......@@ -29,7 +29,9 @@ const val ACCOUNT_GITLAB = "Accounts_OAuth_Gitlab"
const val SITE_URL = "Site_Url"
const val SITE_NAME = "Site_Name"
const val FAVICON_196 = "Assets_favicon_192"
const val FAVICON_512 = "Assets_favicon_512"
const val WIDE_TILE_310 = "Assets_tile_310_wide"
const val USE_REALNAME = "UI_Use_Real_Name"
const val ALLOW_ROOM_NAME_SPECIAL_CHARS = "UI_Allow_room_names_with_special_chars"
const val FAVORITE_ROOMS = "Favorite_Rooms"
......@@ -69,6 +71,9 @@ fun PublicSettings.isGitlabAuthenticationEnabled(): Boolean = this[ACCOUNT_GITLA
fun PublicSettings.isWordpressAuthenticationEnabled(): Boolean = this[ACCOUNT_WORDPRESS]?.value == true
fun PublicSettings.useRealName(): Boolean = this[USE_REALNAME]?.value == true
fun PublicSettings.faviconLarge(): String? = this[FAVICON_512]?.value as String?
fun PublicSettings.favicon(): String? = this[FAVICON_196]?.value as String?
fun PublicSettings.wideTile(): String? = this[WIDE_TILE_310]?.value as String?
// Message settings
fun PublicSettings.showDeletedStatus(): Boolean = this[SHOW_DELETED_STATUS]?.value == true
......@@ -90,4 +95,5 @@ fun PublicSettings.uploadMaxFileSize(): Int {
return this[UPLOAD_MAX_FILE_SIZE]?.value?.let { it as Int } ?: Int.MAX_VALUE
}
fun PublicSettings.baseUrl(): String? = this[SITE_URL]?.value as String
\ No newline at end of file
fun PublicSettings.baseUrl(): String? = this[SITE_URL]?.value as String?
fun PublicSettings.siteName(): String? = this[SITE_NAME]?.value as String?
\ No newline at end of file
package chat.rocket.android.server.domain
interface TokenRepository : chat.rocket.core.TokenRepository {
fun remove(url: String)
fun clear()
}
\ No newline at end of file
package chat.rocket.android.server.domain.model
import se.ansman.kotshi.JsonSerializable
@JsonSerializable
data class Account(
val serverUrl: String,
val serverLogo: String?,
val serverBg: String?,
val userName: String,
val avatar: String?
)
\ No newline at end of file
......@@ -19,4 +19,6 @@ class ConnectionManagerFactory @Inject constructor(private val factory: RocketCh
cache[url] = manager
return manager
}
fun get(url: String) = cache[url]
}
\ No newline at end of file
......@@ -2,16 +2,16 @@ package chat.rocket.android.server.infraestructure
import chat.rocket.common.util.PlatformLogger
import chat.rocket.core.RocketChatClient
import chat.rocket.core.TokenRepository
import chat.rocket.android.server.domain.TokenRepository
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RocketChatClientFactory @Inject constructor(val okHttpClient: OkHttpClient,
val repository: TokenRepository,
val logger: PlatformLogger) {
class RocketChatClientFactory @Inject constructor(private val okHttpClient: OkHttpClient,
private val repository: TokenRepository,
private val logger: PlatformLogger) {
private val cache = HashMap<String, RocketChatClient>()
fun create(url: String): RocketChatClient {
......
package chat.rocket.android.server.infraestructure
import chat.rocket.android.server.domain.ServersRepository
import chat.rocket.android.server.domain.model.Server
import io.reactivex.Completable
import io.reactivex.Single
class RoomServersRepository : ServersRepository {
override val servers: Single<List<Server>>
get() = TODO("not implemented")
override fun saveServer(server: Server): Completable {
TODO("not implemented")
}
override fun updateServer(server: Server): Completable {
TODO("not implemented")
}
}
package chat.rocket.android.server.infraestructure
import android.content.SharedPreferences
import androidx.content.edit
import chat.rocket.android.server.domain.AccountsRepository
import chat.rocket.android.server.domain.model.Account
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.withContext
class SharedPreferencesAccountsRepository(
private val preferences: SharedPreferences,
private val moshi: Moshi
) : AccountsRepository {
override suspend fun save(newAccount: Account) {
withContext(CommonPool) {
val accounts = load()
val newList = accounts.filter { account -> newAccount.serverUrl != account.serverUrl }
.toMutableList()
newList.add(0, newAccount)
save(newList)
}
}
override suspend fun load(): List<Account> = withContext(CommonPool) {
val json = preferences.getString(ACCOUNTS_KEY, "[]")
val type = Types.newParameterizedType(List::class.java, Account::class.java)
val adapter = moshi.adapter<List<Account>>(type)
adapter.fromJson(json) ?: emptyList()
}
override suspend fun remove(serverUrl: String) {
withContext(CommonPool) {
val accounts = load()
val newList = accounts.filter { account -> serverUrl != account.serverUrl }
.toMutableList()
save(newList)
}
}
private fun save(accounts: List<Account>) {
val type = Types.newParameterizedType(List::class.java, Account::class.java)
val adapter = moshi.adapter<List<Account>>(type)
preferences.edit {
putString(ACCOUNTS_KEY, adapter.toJson(accounts))
}
}
}
private const val ACCOUNTS_KEY = "ACCOUNTS_KEY"
\ No newline at end of file
......@@ -15,9 +15,7 @@ class SharedPreferencesSettingsRepository(private val localRepository: LocalRepo
}
override fun get(url: String): PublicSettings {
val settings = localRepository.get("$SETTINGS_KEY$url")!!
settings.let {
return adapter.fromJson(it)!!
}
val settings = localRepository.get("$SETTINGS_KEY$url")
return if (settings == null) hashMapOf() else adapter.fromJson(settings) ?: hashMapOf()
}
}
\ No newline at end of file
package chat.rocket.android.server.presentation
import android.content.Intent
import chat.rocket.android.authentication.ui.newServerIntent
import chat.rocket.android.main.ui.MainActivity
import chat.rocket.android.server.ui.ChangeServerActivity
class ChangeServerNavigator (internal val activity: ChangeServerActivity) {
fun toServerScreen() {
activity.startActivity(activity.newServerIntent())
activity.finish()
}
fun toChatRooms() {
activity.startActivity(Intent(activity, MainActivity::class.java))
activity.finish()
}
}
\ No newline at end of file
package chat.rocket.android.server.presentation
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.infraestructure.ConnectionManagerFactory
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.common.util.ifNull
import javax.inject.Inject
class ChangeServerPresenter @Inject constructor(
private val view: ChangeServerView,
private val navigator: ChangeServerNavigator,
private val strategy: CancelStrategy,
private val saveCurrentServerInteractor: SaveCurrentServerInteractor,
private val getCurrentServerInteractor: GetCurrentServerInteractor,
private val getAccountInteractor: GetAccountInteractor,
private val getAccountsInteractor: GetAccountsInteractor,
private val settingsRepository: SettingsRepository,
private val tokenRepository: TokenRepository,
private val localRepository: LocalRepository,
private val connectionManager: ConnectionManagerFactory
) {
fun loadServer(newUrl: String?) {
launchUI(strategy) {
view.showProgress()
var url = newUrl
if (url == null) { // Try to load next server on the list...
val accounts = getAccountsInteractor.get()
url = accounts.firstOrNull()?.serverUrl
}
url?.let { serverUrl ->
val token = tokenRepository.get(serverUrl)
if (token == null) {
view.showInvalidCredentials()
view.hideProgress()
navigator.toServerScreen()
return@launchUI
}
val settings = settingsRepository.get(serverUrl)
if (settings == null) {
// TODO - reload settings...
}
// Call disconnect on the old url if any...
getCurrentServerInteractor.get()?.let { url ->
connectionManager.get(url)?.disconnect()
}
// Save the current username.
getAccountInteractor.get(serverUrl)?.let { account ->
localRepository.save(LocalRepository.CURRENT_USERNAME_KEY, account.userName)
}
saveCurrentServerInteractor.save(serverUrl)
view.hideProgress()
navigator.toChatRooms()
}.ifNull {
view.hideProgress()
navigator.toServerScreen()
}
}
}
}
\ No newline at end of file
package chat.rocket.android.server.presentation
interface ChangeServerView {
fun showInvalidCredentials()
fun showProgress()
fun hideProgress()
}
\ No newline at end of file
package chat.rocket.android.server.presentation
import chat.rocket.android.BuildConfig
import chat.rocket.android.authentication.server.presentation.VersionCheckView
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.util.VersionInfo
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.serverInfo
import timber.log.Timber
abstract class CheckServerPresenter constructor(private val strategy: CancelStrategy,
private val client: RocketChatClient,
private val view: VersionCheckView) {
internal fun checkServerInfo() {
launchUI(strategy) {
val serverInfo = client.serverInfo()
val thisServerVersion = serverInfo.version
val isRequiredVersion = isRequiredServerVersion(thisServerVersion)
val isRecommendedVersion = isRecommendedServerVersion(thisServerVersion)
if (isRequiredVersion) {
if (isRecommendedVersion) {
Timber.i("Your version is nice! (Requires: 0.62.0, Yours: $thisServerVersion)")
} else {
view.alertNotRecommendedVersion()
}
} else {
if (!isRecommendedVersion) {
view.blockAndAlertNotRequiredVersion()
Timber.i("Oops. Looks like your server is out-of-date! Minimum server version required ${BuildConfig.REQUIRED_SERVER_VERSION}!")
}
}
}
}
private fun isRequiredServerVersion(version: String): Boolean {
return isMinimumVersion(version, getVersionDistilled(BuildConfig.REQUIRED_SERVER_VERSION))
}
private fun isRecommendedServerVersion(version: String): Boolean {
return isMinimumVersion(version, getVersionDistilled(BuildConfig.RECOMMENDED_SERVER_VERSION))
}
private fun isMinimumVersion(version: String, required: VersionInfo): Boolean {
val thisVersion = getVersionDistilled(version)
with(thisVersion) {
if (major < required.major) {
return false
} else if (major > required.major) {
return true
}
if (minor < required.minor) {
return false
} else if (minor > required.minor) {
return true
}
return update >= required.update
}
}
private fun getVersionDistilled(version: String): VersionInfo {
var split = version.split("-")
if (split.isEmpty()) {
return VersionInfo(0, 0, 0, null, "0.0.0")
}
val ver = split[0]
var release: String? = null
if (split.size > 1) {
release = split[1]
}
split = ver.split(".")
val major = getVersionNumber(split, 0)
val minor = getVersionNumber(split, 1)
val update = getVersionNumber(split, 2)
return VersionInfo(
major = major,
minor = minor,
update = update,
release = release,
full = version)
}
private fun getVersionNumber(split: List<String>, index: Int): Int {
return try {
split.getOrNull(index)?.toInt() ?: 0
} catch (ex: NumberFormatException) {
0
}
}
}
\ No newline at end of file
package chat.rocket.android.server.ui
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import chat.rocket.android.server.presentation.ChangeServerPresenter
import chat.rocket.android.server.presentation.ChangeServerView
import chat.rocket.android.util.extensions.showToast
import dagger.android.AndroidInjection
import javax.inject.Inject
class ChangeServerActivity : AppCompatActivity(), ChangeServerView {
@Inject lateinit var presenter: ChangeServerPresenter
var progress: ProgressDialog? = null
override fun onCreate(savedInstanceState: Bundle?) {
AndroidInjection.inject(this)
super.onCreate(savedInstanceState)
val serverUrl: String? = intent.getStringExtra(INTENT_SERVER_URL)
presenter.loadServer(serverUrl)
}
override fun showInvalidCredentials() {
showToast("Missing credentials for this server")
}
override fun showProgress() {
progress = ProgressDialog.show(this, "Rocket.Chat", "Changing Server")
}
override fun hideProgress() {
progress?.dismiss()
}
}
private const val INTENT_SERVER_URL = "INTENT_SERVER_URL"
private const val INTENT_CHAT_ROOM_NAME = "INTENT_CHAT_ROOM_NAME"
private const val INTENT_CHAT_ROOM_TYPE = "INTENT_CHAT_ROOM_TYPE"
fun Context.changeServerIntent(serverUrl: String?): Intent {
return Intent(this, ChangeServerActivity::class.java).apply {
serverUrl?.let { url ->
putExtra(INTENT_SERVER_URL, url)
}
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
\ No newline at end of file
package chat.rocket.android.util
data class VersionInfo(
val major: Int,
val minor: Int,
val update: Int = 0,
val release: String?,
val full: String
)
\ No newline at end of file
......@@ -29,12 +29,12 @@ fun View.fadeIn(startValue: Float = 0f, finishValue: Float = 1f, duration: Long
animate()
.alpha(startValue)
.setDuration(duration)
.setDuration(duration / 2)
.setInterpolator(DecelerateInterpolator())
.withEndAction({
animate()
.alpha(finishValue)
.setDuration(duration)
.setDuration(duration / 2)
.setInterpolator(AccelerateInterpolator()).start()
}).start()
......
package chat.rocket.android.util.extensions
import chat.rocket.android.server.domain.model.Account
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.registerPushToken
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.launch
import timber.log.Timber
suspend fun RocketChatClient.registerPushToken(
token: String,
accounts: List<Account>,
factory: RocketChatClientFactory
) {
launch(CommonPool) {
accounts.forEach { account ->
try {
factory.create(account.serverUrl).registerPushToken(token)
} catch (ex: Exception) {
Timber.d(ex, "Error registering Push token for ${account.serverUrl}")
ex.printStackTrace()
}
}
}
}
\ No newline at end of file
......@@ -3,12 +3,15 @@ package chat.rocket.android.util.extensions
import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.util.Base64
import android.util.Patterns
import android.widget.EditText
import android.widget.TextView
import chat.rocket.android.widget.emoji.EmojiParser
import chat.rocket.android.widget.emoji.EmojiTypefaceSpan
import org.json.JSONObject
import ru.noties.markwon.Markwon
import java.net.URLDecoder
import java.security.SecureRandom
fun String.ifEmpty(value: String): String {
......@@ -33,7 +36,23 @@ fun EditText.erase() {
}
}
fun String.isEmailValid(): Boolean = Patterns.EMAIL_ADDRESS.matcher(this).matches()
fun String.isEmail(): Boolean = Patterns.EMAIL_ADDRESS.matcher(this).matches()
fun String.encodeToBase64(): String {
return Base64.encodeToString(this.toByteArray(charset("UTF-8")), Base64.NO_WRAP)
}
fun String.decodeFromBase64(): String {
return Base64.decode(this, Base64.DEFAULT).toString(charset("UTF-8"))
}
fun String.decodeUrl(): String {
return URLDecoder.decode(this, "UTF-8")
}
fun String.toJsonObject(): JSONObject {
return JSONObject(this)
}
fun generateRandomString(stringLength: Int): String {
val base = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
......
......@@ -27,7 +27,8 @@ fun View.isVisible(): Boolean {
return visibility == View.VISIBLE
}
fun ViewGroup.inflate(@LayoutRes resource: Int): View = LayoutInflater.from(context).inflate(resource, this, false)
fun ViewGroup.inflate(@LayoutRes resource: Int, attachToRoot: Boolean = false): View =
LayoutInflater.from(context).inflate(resource, this, attachToRoot)
fun AppCompatActivity.addFragment(tag: String, layoutId: Int, newInstance: () -> Fragment) {
val fragment = supportFragmentManager.findFragmentByTag(tag) ?: newInstance()
......@@ -36,10 +37,12 @@ fun AppCompatActivity.addFragment(tag: String, layoutId: Int, newInstance: () ->
.commit()
}
fun AppCompatActivity.addFragmentBackStack(tag: String, layoutId: Int, newInstance: () -> Fragment) {
fun AppCompatActivity.addFragmentBackStack(tag: String, layoutId: Int,
newInstance: () -> Fragment) {
val fragment = supportFragmentManager.findFragmentByTag(tag) ?: newInstance()
supportFragmentManager.beginTransaction()
.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left, R.anim.enter_from_left, R.anim.exit_to_right)
.setCustomAnimations(R.anim.enter_from_right, R.anim.exit_to_left,
R.anim.enter_from_left, R.anim.exit_to_right)
.replace(layoutId, fragment, tag)
.addToBackStack(tag)
.commit()
......@@ -52,13 +55,17 @@ fun Activity.hideKeyboard() {
}
}
fun Activity.showToast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) = showToast(getString(resource), duration)
fun Activity.showToast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) =
showToast(getString(resource), duration)
fun Activity.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) = Toast.makeText(this, message, duration).show()
fun Activity.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) =
Toast.makeText(this, message, duration).show()
fun Fragment.showToast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) = showToast(getString(resource), duration)
fun Fragment.showToast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) =
showToast(getString(resource), duration)
fun Fragment.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) = activity!!.showToast(message, duration)
fun Fragment.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) =
activity?.showToast(message, duration)
fun RecyclerView.isAtBottom(): Boolean {
val manager: RecyclerView.LayoutManager? = layoutManager
......
......@@ -13,7 +13,7 @@ import chat.rocket.android.R
import kotlinx.android.synthetic.main.activity_web_view.*
import kotlinx.android.synthetic.main.app_bar.*
fun Context.webViewIntent(webPageUrl: String, casToken: String): Intent {
fun Context.casWebViewIntent(webPageUrl: String, casToken: String): Intent {
return Intent(this, CasWebViewActivity::class.java).apply {
putExtra(INTENT_WEB_PAGE_URL, webPageUrl)
putExtra(INTENT_CAS_TOKEN, casToken)
......@@ -21,7 +21,7 @@ fun Context.webViewIntent(webPageUrl: String, casToken: String): Intent {
}
private const val INTENT_WEB_PAGE_URL = "web_page_url"
private const val INTENT_CAS_TOKEN = "cas_token"
const val INTENT_CAS_TOKEN = "cas_token"
class CasWebViewActivity : AppCompatActivity() {
private lateinit var webPageUrl: String
......@@ -49,14 +49,14 @@ class CasWebViewActivity : AppCompatActivity() {
if (web_view.canGoBack()) {
web_view.goBack()
} else {
finishActivity(false)
closeView()
}
}
private fun setupToolbar() {
toolbar.title = getString(R.string.title_authentication)
toolbar.setNavigationIcon(R.drawable.ic_close_white_24dp)
toolbar.setNavigationOnClickListener { finishActivity(false) }
toolbar.setNavigationOnClickListener { closeView() }
}
@SuppressLint("SetJavaScriptEnabled")
......@@ -64,16 +64,16 @@ class CasWebViewActivity : AppCompatActivity() {
web_view.settings.javaScriptEnabled = true
web_view.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
// The user can be already logged in the CAS, so check if the URL contains the "ticket" word
// (that means he/she is successful authenticated and we don't need to wait until the page is finished.
// The user may have already been logged in the CAS, so check if the URL contains the "ticket" word
// (that means the user is successful authenticated and we don't need to wait until the page is fully loaded).
if (url.contains("ticket")) {
finishActivity(true)
closeView(Activity.RESULT_OK)
}
}
override fun onPageFinished(view: WebView, url: String) {
if (url.contains("ticket")) {
finishActivity(true)
closeView(Activity.RESULT_OK)
} else {
view_loading.hide()
}
......@@ -82,13 +82,9 @@ class CasWebViewActivity : AppCompatActivity() {
web_view.loadUrl(webPageUrl)
}
private fun finishActivity(setResultOk: Boolean) {
if (setResultOk) {
setResult(Activity.RESULT_OK, Intent().putExtra(INTENT_CAS_TOKEN, casToken))
finish()
} else {
super.onBackPressed()
}
private fun closeView(activityResult: Int = Activity.RESULT_CANCELED) {
setResult(activityResult, Intent().putExtra(INTENT_CAS_TOKEN, casToken))
finish()
overridePendingTransition(R.anim.hold, R.anim.slide_down)
}
}
\ No newline at end of file
package chat.rocket.android.webview.oauth.ui
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.net.toUri
import chat.rocket.android.R
import chat.rocket.android.util.extensions.decodeUrl
import chat.rocket.android.util.extensions.toJsonObject
import kotlinx.android.synthetic.main.activity_web_view.*
import kotlinx.android.synthetic.main.app_bar.*
import org.json.JSONObject
fun Context.oauthWebViewIntent(webPageUrl: String, state: String): Intent {
return Intent(this, OauthWebViewActivity::class.java).apply {
putExtra(INTENT_WEB_PAGE_URL, webPageUrl)
putExtra(INTENT_STATE, state)
}
}
private const val INTENT_WEB_PAGE_URL = "web_page_url"
private const val INTENT_STATE = "state"
private const val JSON_CREDENTIAL_TOKEN = "credentialToken"
private const val JSON_CREDENTIAL_SECRET = "credentialSecret"
const val INTENT_OAUTH_CREDENTIAL_TOKEN = "credential_token"
const val INTENT_OAUTH_CREDENTIAL_SECRET = "credential_secret"
// Shows a WebView to the user authenticate with your Gitlab credentials.
class OauthWebViewActivity : AppCompatActivity() {
private lateinit var webPageUrl: String
private lateinit var state: String
private var isWebViewSetUp: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_web_view)
webPageUrl = intent.getStringExtra(INTENT_WEB_PAGE_URL)
requireNotNull(webPageUrl) { "no web_page_url provided in Intent extras" }
state = intent.getStringExtra(INTENT_STATE)
requireNotNull(state) { "no state provided in Intent extras" }
setupToolbar()
}
override fun onResume() {
super.onResume()
if (!isWebViewSetUp) {
setupWebView()
isWebViewSetUp = true
}
}
override fun onBackPressed() {
if (web_view.canGoBack()) {
web_view.goBack()
} else {
closeView()
}
}
private fun setupToolbar() {
with(toolbar) {
title = getString(R.string.title_authentication)
setNavigationIcon(R.drawable.ic_close_white_24dp)
setNavigationOnClickListener { closeView() }
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
with(web_view.settings) {
javaScriptEnabled = true
// TODO Remove this workaround that is required to make Google OAuth to work. We should use Custom Tabs instead. See https://github.com/RocketChat/Rocket.Chat.Android/issues/968
if (webPageUrl.contains("google")) {
userAgentString = "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/43.0.2357.65 Mobile Safari/535.19"
}
}
web_view.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
if (url.contains(JSON_CREDENTIAL_TOKEN) && url.contains(JSON_CREDENTIAL_SECRET)) {
if (isStateValid(url)) {
val jsonResult = url.decodeUrl()
.substringAfter("#")
.toJsonObject()
val credentialToken = getCredentialToken(jsonResult)
val credentialSecret = getCredentialSecret(jsonResult)
if (credentialToken.isNotEmpty() && credentialSecret.isNotEmpty()) {
closeView(Activity.RESULT_OK, credentialToken, credentialSecret)
}
}
}
view_loading.hide()
}
}
web_view.loadUrl(webPageUrl)
}
// If the states matches, then try to get the code, otherwise the request was created by a third party and the process should be aborted.
private fun isStateValid(url: String): Boolean =
url.substringBefore("#").toUri().getQueryParameter(INTENT_STATE) == state
private fun getCredentialToken(json: JSONObject): String =
json.optString(JSON_CREDENTIAL_TOKEN)
private fun getCredentialSecret(json: JSONObject): String =
json.optString(JSON_CREDENTIAL_SECRET)
private fun closeView(activityResult: Int = Activity.RESULT_CANCELED, credentialToken: String? = null, credentialSecret: String? = null) {
setResult(activityResult, Intent().putExtra(INTENT_OAUTH_CREDENTIAL_TOKEN, credentialToken).putExtra(INTENT_OAUTH_CREDENTIAL_SECRET, credentialSecret))
finish()
overridePendingTransition(R.anim.hold, R.anim.slide_down)
}
}
\ No newline at end of file
......@@ -6,5 +6,6 @@ interface CompletionStrategy {
fun getItem(prefix: String, position: Int): SuggestionModel
fun autocompleteItems(prefix: String): List<SuggestionModel>
fun addAll(list: List<SuggestionModel>)
fun addPinned(list: List<SuggestionModel>)
fun size(): Int
}
\ No newline at end of file
......@@ -2,14 +2,19 @@ package chat.rocket.android.widget.autocompletion.strategy.regex
import chat.rocket.android.widget.autocompletion.model.SuggestionModel
import chat.rocket.android.widget.autocompletion.strategy.CompletionStrategy
import chat.rocket.android.widget.autocompletion.ui.SuggestionsAdapter
import chat.rocket.android.widget.autocompletion.ui.SuggestionsAdapter.Companion.RESULT_COUNT_UNLIMITED
import java.util.concurrent.CopyOnWriteArrayList
internal class StringMatchingCompletionStrategy(private val threshold: Int = -1) : CompletionStrategy {
internal class StringMatchingCompletionStrategy(private val threshold: Int = RESULT_COUNT_UNLIMITED) : CompletionStrategy {
private val list = CopyOnWriteArrayList<SuggestionModel>()
private val pinnedList = mutableListOf<SuggestionModel>()
init {
check(threshold >= RESULT_COUNT_UNLIMITED)
}
override fun autocompleteItems(prefix: String): List<SuggestionModel> {
val result = list.filter {
val partialResult = list.filter {
it.searchList.forEach { word ->
if (word.contains(prefix, ignoreCase = true)) {
return@filter true
......@@ -17,13 +22,23 @@ internal class StringMatchingCompletionStrategy(private val threshold: Int = -1)
}
false
}.sortedByDescending { it.pinned }
return if (threshold == SuggestionsAdapter.UNLIMITED_RESULT_COUNT) result else result.take(threshold)
return if (threshold == RESULT_COUNT_UNLIMITED)
partialResult.toList()
else {
val result = partialResult.take(threshold).toMutableList()
result.addAll(pinnedList)
result.toList()
}
}
override fun addAll(list: List<SuggestionModel>) {
this.list.addAllAbsent(list)
}
override fun addPinned(list: List<SuggestionModel>) {
this.pinnedList.addAll(list)
}
override fun getItem(prefix: String, position: Int): SuggestionModel {
return list[position]
}
......
......@@ -27,5 +27,9 @@ class TrieCompletionStrategy : CompletionStrategy {
}
}
override fun addPinned(list: List<SuggestionModel>) {
}
override fun size() = items.size
}
\ No newline at end of file
......@@ -10,10 +10,10 @@ import kotlin.properties.Delegates
abstract class SuggestionsAdapter<VH : BaseSuggestionViewHolder>(
val token: String,
val constraint: Int = CONSTRAINT_UNBOUND,
threshold: Int = MAX_RESULT_COUNT) : RecyclerView.Adapter<VH>() {
threshold: Int = MAX_RESULT_COUNT) : RecyclerView.Adapter<VH>() {
companion object {
// Any number of results.
const val UNLIMITED_RESULT_COUNT = -1
const val RESULT_COUNT_UNLIMITED = -1
// Trigger suggestions only if on the line start.
const val CONSTRAINT_BOUND_TO_START = 0
// Trigger suggestions from anywhere.
......@@ -21,12 +21,14 @@ abstract class SuggestionsAdapter<VH : BaseSuggestionViewHolder>(
// Maximum number of results to display by default.
private const val MAX_RESULT_COUNT = 5
}
private var itemType: Type? = null
private var itemClickListener: ItemClickListener? = null
// Called to gather results when no results have previously matched.
private var providerExternal: ((query: String) -> Unit)? = null
private var pinnedSuggestions: List<SuggestionModel>? = null
// Maximum number of results/suggestions to display.
private var resultsThreshold: Int = if (threshold > 0) threshold else UNLIMITED_RESULT_COUNT
private var resultsThreshold: Int = if (threshold > 0) threshold else RESULT_COUNT_UNLIMITED
// The strategy used for suggesting completions.
private val strategy: CompletionStrategy = StringMatchingCompletionStrategy(resultsThreshold)
// Current input term to look up for suggestions.
......@@ -53,6 +55,15 @@ abstract class SuggestionsAdapter<VH : BaseSuggestionViewHolder>(
return strategy.autocompleteItems(currentTerm)[position]
}
/**
* Set suggestions that should always appear when prompted.
*
* @param suggestions The list of suggestions that will be pinned.
*/
fun setPinnedSuggestions(suggestions: List<SuggestionModel>) {
this.strategy.addPinned(suggestions)
}
fun autocomplete(newTerm: String) {
this.currentTerm = newTerm.toLowerCase().trim()
}
......
......@@ -23,23 +23,19 @@ import chat.rocket.android.R
import chat.rocket.android.widget.autocompletion.model.SuggestionModel
import chat.rocket.android.widget.autocompletion.ui.SuggestionsAdapter.Companion.CONSTRAINT_BOUND_TO_START
import java.lang.ref.WeakReference
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
/**
* This is a special index that means we're not at an autocompleting state.
*/
// This is a special index that means we're not at an autocompleting state.
private const val NO_STATE_INDEX = 0
class SuggestionsView : FrameLayout, TextWatcher {
private val recyclerView: RecyclerView
private val registeredTokens = CopyOnWriteArrayList<String>()
// Maps tokens to their respective adapters.
private val adaptersByToken = hashMapOf<String, SuggestionsAdapter<out BaseSuggestionViewHolder>>()
private val externalProvidersByToken = hashMapOf<String, ((query: String) -> Unit)>()
private val localProvidersByToken = hashMapOf<String, HashMap<String, List<SuggestionModel>>>()
private var editor: WeakReference<EditText>? = null
private var completionStartIndex = AtomicInteger(NO_STATE_INDEX)
private var completionOffset = AtomicInteger(NO_STATE_INDEX)
private var maxHeight: Int = 0
companion object {
......@@ -66,7 +62,7 @@ class SuggestionsView : FrameLayout, TextWatcher {
// If we have a deletion.
if (after == 0) {
val deleted = s.subSequence(start, start + count).toString()
if (adaptersByToken.containsKey(deleted) && completionStartIndex.get() > NO_STATE_INDEX) {
if (adaptersByToken.containsKey(deleted) && completionOffset.get() > NO_STATE_INDEX) {
// We have removed the '@', '#' or any other action token so halt completion.
cancelSuggestions(true)
}
......@@ -77,6 +73,11 @@ class SuggestionsView : FrameLayout, TextWatcher {
// If we don't have any adapter bound to any token bail out.
if (adaptersByToken.isEmpty()) return
if (editor?.get() != null && editor?.get()?.selectionStart ?: 0 <= completionOffset.get()) {
completionOffset.set(NO_STATE_INDEX)
collapse()
}
val new = s.subSequence(start, start + count).toString()
if (adaptersByToken.containsKey(new)) {
val constraint = adapter(new).constraint
......@@ -84,8 +85,8 @@ class SuggestionsView : FrameLayout, TextWatcher {
return
}
swapAdapter(getAdapterForToken(new)!!)
completionStartIndex.compareAndSet(NO_STATE_INDEX, start + 1)
editor?.let {
completionOffset.compareAndSet(NO_STATE_INDEX, start + 1)
this.editor?.let {
// Disable keyboard suggestions when autocompleting.
val editText = it.get()
if (editText != null) {
......@@ -97,13 +98,13 @@ class SuggestionsView : FrameLayout, TextWatcher {
if (new.startsWith(" ")) {
// just halts the completion execution
cancelSuggestions(false)
cancelSuggestions(true)
return
}
val prefixEndIndex = editor?.get()?.selectionStart ?: NO_STATE_INDEX
if (prefixEndIndex == NO_STATE_INDEX || prefixEndIndex < completionStartIndex.get()) return
val prefix = s.subSequence(completionStartIndex.get(), editor?.get()?.selectionStart ?: completionStartIndex.get()).toString()
val prefixEndIndex = this.editor?.get()?.selectionStart ?: NO_STATE_INDEX
if (prefixEndIndex == NO_STATE_INDEX || prefixEndIndex < completionOffset.get()) return
val prefix = s.subSequence(completionOffset.get(), this.editor?.get()?.selectionStart ?: completionOffset.get()).toString()
recyclerView.adapter?.let {
it as SuggestionsAdapter
// we need to look up only after the '@'
......@@ -157,7 +158,7 @@ class SuggestionsView : FrameLayout, TextWatcher {
val adapter = adapter(token)
localProvidersByToken.getOrPut(token, { hashMapOf() })
.put(adapter.term(), list)
if (completionStartIndex.get() > NO_STATE_INDEX && adapter.itemCount == 0) expand()
if (completionOffset.get() > NO_STATE_INDEX && adapter.itemCount == 0) expand()
adapter.addItems(list)
}
return this
......@@ -199,7 +200,7 @@ class SuggestionsView : FrameLayout, TextWatcher {
// Reset completion start index only if we've deleted the token that triggered completion or
// we finished the completion process.
if (haltCompletion) {
completionStartIndex.set(NO_STATE_INDEX)
completionOffset.set(NO_STATE_INDEX)
}
collapse()
// Re-enable keyboard suggestions.
......@@ -212,7 +213,7 @@ class SuggestionsView : FrameLayout, TextWatcher {
private fun insertSuggestionOnEditor(item: SuggestionModel) {
editor?.get()?.let {
val suggestionText = item.text
it.text.replace(completionStartIndex.get(), it.selectionStart, "$suggestionText ")
it.text.replace(completionOffset.get(), it.selectionStart, "$suggestionText ")
}
}
......
......@@ -14,15 +14,21 @@ class EmojiParser {
*/
fun parse(text: CharSequence): CharSequence {
val unicodedText = EmojiRepository.shortnameToUnicode(text, true)
val spannableString = SpannableString.valueOf(unicodedText)
// Look for groups of emojis, set a CustomTypefaceSpan with the emojione font
val length = spannableString.length
var spannable = SpannableString.valueOf(unicodedText)
val typeface = EmojiRepository.cachedTypeface
// Look for groups of emojis, set a EmojiTypefaceSpan with the emojione font.
val length = spannable.length
var inEmoji = false
var emojiStart = 0
var offset = 0
while (offset < length) {
val codepoint = unicodedText.codePointAt(offset)
val count = Character.charCount(codepoint)
// Skip control characters.
if (codepoint == 0x2028) {
offset += count
continue
}
if (codepoint >= 0x200) {
if (!inEmoji) {
emojiStart = offset
......@@ -30,18 +36,25 @@ class EmojiParser {
inEmoji = true
} else {
if (inEmoji) {
spannableString.setSpan(EmojiTypefaceSpan("sans-serif", EmojiRepository.cachedTypeface),
spannable.setSpan(EmojiTypefaceSpan("sans-serif", typeface),
emojiStart, offset, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
inEmoji = false
}
offset += count
if (offset >= length && inEmoji) {
spannableString.setSpan(EmojiTypefaceSpan("sans-serif", EmojiRepository.cachedTypeface),
spannable.setSpan(EmojiTypefaceSpan("sans-serif", typeface),
emojiStart, offset, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
return spannableString
return spannable
}
private fun calculateSurrogatePairs(scalar: Int): Pair<Int, Int> {
val temp: Int = (scalar - 0x10000) / 0x400
val s1: Int = Math.floor(temp.toDouble()).toInt() + 0xD800
val s2: Int = ((scalar - 0x10000) % 0x400) + 0xDC00
return Pair(s1, s2)
}
}
}
\ No newline at end of file
......@@ -54,6 +54,10 @@ object EmojiRepository {
*/
fun getAll() = ALL_EMOJIS
// fun findEmojiByUnicode(unicode: Int) {
// ALL_EMOJIS.find { it.unicode == }
// }
/**
* Get all emojis for a given category.
*
......@@ -119,10 +123,7 @@ object EmojiRepository {
var result: String = input.toString()
while (matcher.find()) {
val unicode = shortNameToUnicode.get(":${matcher.group(1)}:")
if (unicode == null) {
continue
}
val unicode = shortNameToUnicode.get(":${matcher.group(1)}:") ?: continue
if (supported) {
result = result.replace(":" + matcher.group(1) + ":", unicode)
......@@ -159,9 +160,7 @@ object EmojiRepository {
private fun buildStringListFromJsonArray(array: JSONArray): List<String> {
val list = ArrayList<String>(array.length())
for (i in 0..array.length() - 1) {
list.add(array.getString(i))
}
(0 until array.length()).mapTo(list) { array.getString(it) }
return list
}
......
......@@ -17,22 +17,22 @@ class EmojiTypefaceSpan(family: String, private val newType: Typeface) : Typefac
private fun applyCustomTypeFace(paint: Paint, tf: Typeface) {
val oldStyle: Int
val old = paint.getTypeface()
val old = paint.typeface
if (old == null) {
oldStyle = 0
} else {
oldStyle = old.getStyle()
oldStyle = old.style
}
val fake = oldStyle and tf.style.inv()
if (fake and Typeface.BOLD != 0) {
paint.setFakeBoldText(true)
paint.isFakeBoldText = true
}
if (fake and Typeface.ITALIC != 0) {
paint.setTextSkewX(-0.25f)
paint.textSkewX = -0.25f
}
paint.setTypeface(tf)
paint.typeface = tf
}
}
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:endColor="#00000000"
android:centerColor="#30000000"
android:startColor="#C0000000"
android:type="linear" />
</shape>
\ No newline at end of file
......@@ -3,9 +3,7 @@
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF2F343D"
android:fillColor="#FF000000"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>
\ No newline at end of file
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z"/>
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="1055.0303"
android:viewportWidth="1055.0303">
<group
android:translateX="281.28394"
android:translateY="271.51514">
<path
android:fillColor="#CC3333"
android:pathData="M491.3,255.3c0,-24.1 -7.2,-47.2 -21.4,-68.7c-12.8,-19.3 -30.7,-36.4 -53.2,-50.7c-43.5,-27.8 -100.6,-43.1 -160.9,-43.1c-20.1,0 -40,1.7 -59.2,5.1c-11.9,-11.2 -25.9,-21.2 -40.7,-29.2c-79,-38.3 -144.6,-0.9 -144.6,-0.9s60.9,50.1 51,93.9c-27.3,27 -42,59.6 -42,93.6c0,0.1 0,0.2 0,0.3c0,0.1 0,0.2 0,0.3c0,33.9 14.8,66.6 42,93.6c9.9,43.9 -51,93.9 -51,93.9s65.5,37.4 144.6,-0.9c14.8,-8 28.8,-18 40.7,-29.2c19.2,3.4 39.1,5.1 59.2,5.1c60.3,0 117.4,-15.3 160.9,-43.1c22.5,-14.4 40.4,-31.5 53.2,-50.7c14.2,-21.5 21.4,-44.6 21.4,-68.7c0,-0.1 0,-0.2 0,-0.3C491.3,255.6 491.3,255.4 491.3,255.3z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M255.9,124.2c113.9,0 206.3,59 206.3,131.8c0,72.8 -92.4,131.8 -206.3,131.8c-25.4,0 -49.7,-2.9 -72.1,-8.3c-22.8,27.4 -73,65.6 -121.7,53.3c15.9,-17 39.4,-45.8 34.3,-93.2c-29.2,-22.7 -46.8,-51.8 -46.8,-83.5C49.6,183.2 142,124.2 255.9,124.2" />
<path
android:fillColor="#CC3333"
android:pathData="M255.9,256m-27.4,0a27.4,27.4 0,1 1,54.8 0a27.4,27.4 0,1 1,-54.8 0" />
<path
android:fillColor="#CC3333"
android:pathData="M351.2,256m-27.4,0a27.4,27.4 0,1 1,54.8 0a27.4,27.4 0,1 1,-54.8 0" />
<path
android:fillColor="#CC3333"
android:pathData="M160.6,256m-27.4,0a27.4,27.4 0,1 1,54.8 0a27.4,27.4 0,1 1,-54.8 0" />
<path
android:fillColor="#CCCCCC"
android:pathData="M255.8,372.8c-25.4,0 -56.2,-4.9 -78.7,-9.5c-20.1,21 -53.7,52.7 -99.6,50.3c-5.7,8.6 -10.2,13.5 -15.5,19.2c48.7,12.3 98.9,-25.8 121.7,-53.3c22.4,5.4 46.7,8.3 72.1,8.3c113,0 204.8,-58.1 206.3,-130C460.7,320.1 368.8,372.8 255.8,372.8z" />
<path
android:fillColor="#00000000"
android:pathData="M172,350.9"
android:strokeColor="#000000"
android:strokeWidth="1" />
<path
android:fillColor="#00000000"
android:pathData="M200.4,422.9"
android:strokeColor="#000000"
android:strokeWidth="1" />
</group>
</vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M3,18h6v-2L3,16v2zM3,6v2h18L21,6L3,6zM3,13h12v-2L3,11v2z" />
</vector>
......@@ -2,9 +2,11 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/darkGray" />
<solid android:color="@color/colorPrimary" />
<size
android:width="4dp"
android:height="4dp" />
<corners android:radius="8dp" />
</shape>
\ No newline at end of file
......@@ -23,11 +23,26 @@
android:layout_height="match_parent" />
</LinearLayout>
<android.support.design.widget.NavigationView
android:id="@+id/view_navigation"
<FrameLayout
android:id="@+id/navigation_container"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:menu="@menu/navigation" />
android:layout_gravity="start">
<android.support.design.widget.NavigationView
android:id="@+id/view_navigation"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:menu="@menu/navigation"
app:headerLayout="@layout/nav_header" />
<android.support.v7.widget.RecyclerView
android:id="@+id/accounts_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/nav_header_height"
android:elevation="20dp"
android:background="@color/white"
android:alpha="0"
android:visibility="gone"/>
</FrameLayout>
</android.support.v4.widget.DrawerLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp">
<RadioGroup
android:id="@+id/radio_group_sort"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RadioButton
android:id="@+id/radio_sort_alphabetical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="@string/dialog_sort_by_alphabet"
android:textSize="18sp" />
<RadioButton
android:id="@+id/radio_sort_activity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="@string/dialog_sort_by_activity"
android:textSize="18sp" />
</RadioGroup>
<CheckBox
android:id="@+id/checkbox_group_by_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:padding="8dp"
android:text="@string/dialog_group_by_type"
android:textSize="18sp" />
<!--TODO Add checkbox for "Group favourites after sdk support"-->
</LinearLayout>
\ No newline at end of file
......@@ -113,6 +113,7 @@
android:layout_width="290dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:contentDescription="@string/msg_content_description_log_in_using_facebook"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_facebook"
......@@ -124,6 +125,7 @@
android:layout_width="290dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:contentDescription="@string/msg_content_description_log_in_using_github"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_github"
......@@ -135,6 +137,7 @@
android:layout_width="290dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:contentDescription="@string/msg_content_description_log_in_using_google"
android:foreground="?android:attr/selectableItemBackground"
android:src="@drawable/ic_google"
......@@ -146,6 +149,7 @@
android:layout_width="290dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:contentDescription="@string/msg_content_description_log_in_using_linkedin"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_linkedin"
......@@ -157,6 +161,7 @@
android:layout_width="290dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:contentDescription="@string/msg_content_description_log_in_using_meteor"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_meteor"
......@@ -168,6 +173,7 @@
android:layout_width="290dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:contentDescription="@string/msg_content_description_log_in_using_twitter"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_twitter"
......@@ -179,6 +185,7 @@
android:layout_width="290dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:contentDescription="@string/msg_content_description_log_in_using_gitlab"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_gitlab"
......
......@@ -27,7 +27,7 @@
android:digits="0123456789abcdefghijklmnopqrstuvwxyz.-/:"
android:inputType="textUri"
android:paddingEnd="0dp"
android:paddingStart="2dp" />
android:paddingStart="4dp" />
<TextView
android:id="@+id/text_server_protocol"
......
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground" >
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/server_logo"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:actualImageScaleType="centerInside" />
<TextView
android:id="@+id/text_server_url"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="@id/server_logo"
app:layout_constraintStart_toEndOf="@id/server_logo"
app:layout_constraintEnd_toEndOf="parent"
tools:text="https://open.rocket.chat" />
<TextView
android:id="@+id/text_username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="@id/server_logo"
app:layout_constraintStart_toEndOf="@id/server_logo"
app:layout_constraintEnd_toEndOf="parent"
tools:text="Lucio Maciel"/>
</android.support.constraint.ConstraintLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground" >
<ImageView
android:id="@+id/server_logo"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:layout_marginBottom="8dp"
android:src="@drawable/ic_add_24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:text="@string/action_add_account"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/server_logo"
app:layout_constraintEnd_toEndOf="parent" />
</android.support.constraint.ConstraintLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/darkGray" />
<TextView
android:id="@+id/text_chatroom_header"
style="@style/ChatRooms.Header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:paddingEnd="@dimen/screen_edge_left_and_right_padding"
android:paddingStart="@dimen/screen_edge_left_and_right_padding"
android:text="@string/chatroom_header" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/darkGray" />
</LinearLayout>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:paddingBottom="@dimen/message_item_top_and_bottom_padding"
android:paddingEnd="@dimen/screen_edge_left_and_right_padding"
android:paddingStart="@dimen/screen_edge_left_and_right_padding"
android:paddingTop="@dimen/message_item_top_and_bottom_padding">
<View
android:id="@+id/quote_bar"
android:layout_width="4dp"
android:layout_height="0dp"
android:layout_marginStart="56dp"
android:background="@drawable/quote_vertical_bar"
app:layout_constraintBottom_toTopOf="@+id/recycler_view_reactions"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/top_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:orientation="horizontal"
app:layout_constraintLeft_toRightOf="@+id/quote_bar"
app:layout_constraintTop_toBottomOf="@id/new_messages_notif">
<TextView
android:id="@+id/text_sender"
style="@style/Sender.Name.TextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorPrimary"
tools:text="Ronald Perkins" />
<TextView
android:id="@+id/text_message_time"
style="@style/Timestamp.TextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
tools:text="11:45 PM" />
</LinearLayout>
<TextView
android:id="@+id/text_content"
style="@style/Message.Quote.TextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/top_container"
app:layout_constraintTop_toBottomOf="@+id/top_container"
tools:text="This is a multiline chat message from Bertie that will take more than just one line of text. I have sure that everything is amazing!" />
<include
layout="@layout/layout_reactions"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/quote_bar"
app:layout_constraintTop_toBottomOf="@+id/text_content" />
</android.support.constraint.ConstraintLayout>
\ No newline at end of file
......@@ -6,6 +6,17 @@
android:layout_height="wrap_content"
android:background="@color/colorPrimary">
<View
android:id="@+id/quote_bar"
android:layout_width="4dp"
android:layout_height="0dp"
android:background="@drawable/quote_vertical_bar"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/image_view_action_cancel_quote"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/text_view_action_text"
android:layout_width="0dp"
......
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_header_height">
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/server_logo"
android:layout_width="0dp"
android:layout_height="0dp"
android:foreground="@drawable/black_gradient"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:actualImageScaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" />
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/image_avatar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:roundedCornerRadius="3dp"
tools:src="@tools:sample/avatars" />
<android.support.constraint.ConstraintLayout
android:id="@+id/account_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginTop="16dp"
android:padding="4dp"
android:elevation="2dp"
android:background="?selectableItemBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@+id/image_avatar"
app:layout_constraintTop_toBottomOf="@+id/image_avatar">
<TextView
android:id="@+id/text_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@+id/account_expand"
app:layout_constraintStart_toStartOf="parent"
tools:text="Lucio Maciel" />
<TextView
android:id="@+id/text_server"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="@color/white"
app:layout_constraintEnd_toStartOf="@+id/account_expand"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text_name"
tools:text="https://open.rocket.chat" />
<ImageView
android:id="@+id/account_expand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_expand_more_24dp"
android:tint="@color/whitesmoke"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
</android.support.constraint.ConstraintLayout>
\ No newline at end of file
......@@ -6,9 +6,9 @@
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:layout_marginEnd="2dp"
android:layout_marginLeft="4dp"
android:layout_marginLeft="8dp"
android:layout_marginRight="2dp"
android:layout_marginStart="4dp"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:background="@color/suggestion_background_color">
......@@ -22,7 +22,8 @@
android:id="@+id/image_avatar"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="4dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
app:roundedCornerRadius="3dp"
tools:src="@tools:sample/avatars" />
......
......@@ -9,4 +9,10 @@
app:actionViewClass="android.support.v7.widget.SearchView"
app:showAsAction="ifRoom|collapseActionView" />
<item
android:id="@+id/action_sort"
android:icon="@drawable/ic_sort"
android:title="@string/menu_chatroom_sort"
app:showAsAction="always" />
</menu>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
\ No newline at end of file
<resources>
<!-- Titles -->
<string name="title_sign_in_your_server">अपने सर्वर में साइन इन करें</string>
<string name="title_log_in">लॉग इन करें</string>
<string name="title_sign_up">साइन अप करें</string>
<string name="title_authentication">प्रमाणीकरण</string>
<string name="title_legal_terms">कानूनी शर्तें</string>
<string name="title_chats">चैट</string>
<string name="title_profile">प्रोफाइल</string>
<string name="title_members">सदस्य (%d)</string>
<string name="title_settings">सेटिंग्स</string>
<string name="title_password">पासवर्ड बदलें</string>
<string name="title_update_profile">प्रोफ़ाइल अपडेट करें</string>
<!-- Actions -->
<string name="action_connect">जुडिये</string>
<string name="action_login_or_sign_up">लॉग इन करने या खाता बनाने के लिए इस बटन को टैप करें</string>
<string name="action_terms_of_service">सेवा की शर्तें</string>
<string name="action_privacy_policy">गोपनीयता नीति</string>
<string name="action_search">खोजें</string>
<string name="action_update">अद्यतन करें</string>
<string name="action_settings">सेटिंग्स</string>
<string name="action_logout">लोग आउट करें</string>
<string name="action_files">फ़ाइलें</string>
<string name="action_confirm_password">पासवर्ड परिवर्तन की पुष्टि करें</string>
<string name="action_join_chat">चैट में शामिल हों</string>
<string name="action_add_account">खाता जोड़ो</string>
<!-- Settings List -->
<string-array name="settings_actions">
<item name="item_password">पासवर्ड बदलें</item>
</string-array>
<!-- Regular information messages -->
<string name="msg_no_internet_connection">कोई इंटरनेट कनेक्शन नहीं है</string>
<string name="msg_generic_error">क्षमा करें, एक त्रुटि हुई है, कृपया पुनः प्रयास करें</string>
<string name="msg_no_data_to_display">डेटा प्रदर्शित करने के लिए उपलब्ध नहीं हैं</string>
<string name="msg_profile_update_successfully">प्रोफ़ाइल सफलतापूर्वक अपडेट हो गया है</string>
<string name="msg_username">यूजरनेम</string>
<string name="msg_username_or_email">यूजरनेम या ईमेल</string>
<string name="msg_password">पासवर्ड</string>
<string name="msg_name">नाम</string>
<string name="msg_email">ईमेल</string>
<string name="msg_avatar_url">अवतार यूआरएल</string>
<string name="msg_or_continue_using_social_accounts">या सामाजिक खाते का उपयोग करना जारी रखें</string>
<string name="msg_new_user">नया उपयोगकर्ता? %1$s</string>
<string name="msg_new_user_agreement">आगे बढ़कर आप हमारे %1$s और %2$s से सहमत हो रहे हैं</string>
<string name="msg_2fa_code">कोड 2FA</string>
<string name="msg_yesterday">कल</string>
<string name="msg_message">संदेश</string>
<string name="msg_this_room_is_read_only">यह रूम केवल पढ़ने के लिए है</string>
<string name="msg_invalid_2fa_code">अमान्य 2FA कोड</string>
<string name="msg_invalid_file">अवैध फाइल</string>
<string name="msg_invalid_server_url">अमान्य सर्वर यूआरL</string>
<string name="msg_content_description_log_in_using_facebook">Facebook द्वारा लॉगिन करें</string>
<string name="msg_content_description_log_in_using_github">Github द्वारा लॉगिन करें</string>
<string name="msg_content_description_log_in_using_google">Google द्वारा लॉगिन करें</string>
<string name="msg_content_description_log_in_using_linkedin">Linkedin द्वारा लॉगिन करें</string>
<string name="msg_content_description_log_in_using_meteor">Meteor द्वारा लॉगिन करें</string>
<string name="msg_content_description_log_in_using_twitter">Twitter द्वारा लॉगिन करें</string>
<string name="msg_content_description_log_in_using_gitlab">Gitlab द्वारा लॉगिन करें</string>
<string name="msg_content_description_send_message">मेसेज भेजें</string>
<string name="msg_content_description_show_attachment_options">अटैचमेंट विकल्प दिखाएं</string>
<string name="msg_you">आप</string>
<string name="msg_unknown">अनजान</string>
<string name="msg_email_address">ई-मेल एड्रेस</string>
<string name="msg_utc_offset">यूटीसी ऑफ़सेट</string>
<string name="msg_new_password">नया पासवर्ड दर्ज करें</string>
<string name="msg_confirm_password">नए पासवर्ड की पुष्टि करें</string>
<string name="msg_preview_video">वीडियो</string>
<string name="msg_preview_audio">ऑडियो</string>
<string name="msg_preview_photo">तस्वीरें</string>
<string name="msg_unread_messages">अपठित संदेश</string>
<string name="msg_no_messages_yet">अभी तक कोई पोस्ट नहीं</string>
<string name="msg_requires_username">उपयोगकर्ता नाम आवश्यक है: कृपया, वेब संस्करण के माध्यम से एक उपयोगकर्ता नाम बनाएं और लॉग इन करने के लिए वापस आएँ</string>
<string name="msg_ok">OK</string>
<string name="msg_ver_not_recommended">
ऐसा लगता है कि आपका सर्वर संस्करण अनुशंसित संस्करण %1$s के नीचे है।\nआप अभी भी लॉगिन कर सकते हैं लेकिन आप अप्रत्याशित व्यवहार का अनुभव कर सकते हैं
</string>
<string name="msg_ver_not_minimum">
ऐसा लगता है कि आपका सर्वर संस्करण न्यूनतम आवश्यक संस्करण %1$s से कम है।\nकृपया लॉगिन करने के लिए अपने सर्वर को अपग्रेड करें!
</string>
<!-- System messages -->
<string name="message_room_name_changed">%2$s ने रूम का नाम बदलकर %1$s किया</string>
<string name="message_user_added_by">उपयोगकर्ता %1$s द्वारा %2$s को जोड़ा गया</string>
<string name="message_user_removed_by">उपयोगकर्ता %1$s द्वारा %2$s को निकाला गया</string>
<string name="message_user_left">ने चैनल को छोड़ दिया है</string>
<string name="message_user_joined_channel">चैनल में शामिल हो गया है</string>
<string name="message_welcome">%s का स्वागत करते हैं</string>
<string name="message_removed">संदेश हटाया गया</string>
<string name="message_pinned">एक संदेश पिन किया:</string>
<!-- Message actions -->
<string name="action_msg_reply">जवाब दें</string>
<string name="action_msg_edit">संपादन करें</string>
<string name="action_msg_copy">कॉपी</string>
<string name="action_msg_quote">उद्धरण</string>
<string name="action_msg_delete">मिटायें</string>
<string name="action_msg_pin">संदेश को पिन करें</string>
<string name="action_msg_unpin">संदेश को पिन से हटाएँ</string>
<string name="action_msg_star">संदेश को स्टार करें</string>
<string name="action_msg_share">शेयर करें</string>
<string name="action_title_editing">संपादन संदेश</string>
<string name="action_msg_add_reaction">प्रतिक्रिया जोड़ें</string>
<!-- Permission messages -->
<string name="permission_editing_not_allowed">संपादन की अनुमति नहीं है</string>
<string name="permission_deleting_not_allowed">हटाने की अनुमति नहीं है</string>
<string name="permission_pinning_not_allowed">पिनि करने की अनुमति नहीं है</string>
<!-- Members List -->
<string name="title_members_list">सदस्यों की सूची</string>
<!-- Pinned Messages -->
<string name="title_pinned_messages">पिन किए गए संदेश</string>
<!-- Upload Messages -->
<string name="max_file_size_exceeded">फ़ाइल का आकार %1$d बाइट्स ने %2$d बाइट्स के अधिकतम अपलोड आकार को पार कर लिया है</string>
<!-- Socket status -->
<string name="status_connected">कनेक्टेड</string>
<string name="status_disconnected">डिस्कनेक्टेड</string>
<string name="status_connecting">कनेक्टिंग</string>
<string name="status_authenticating">प्रमाणीकरण</string>
<string name="status_disconnecting">डिसकनेक्टिंग</string>
<string name="status_waiting">%d सेकेंड में कनेक्ट हो रहा है</string>
<!--Suggestions-->
<string name="suggest_all_description">इस कमरे में सभी को सूचित करें</string>
<string name="suggest_here_description">इस कमरे में सक्रिय उपयोगकर्ताओं को सूचित करना</string>
<!-- Slash Commands -->
<string name="Slash_Gimme_Description">आपके संदेश से पहले ༼ つ ◕_◕ ༽つ दिखाता है</string>
<string name="Slash_LennyFace_Description">आपके संदेश के बाद ( ͡° ͜ʖ ͡°) दिखाता है</string>
<string name="Slash_Shrug_Description">आपके संदेश के बाद ¯\_(ツ)_/¯ दिखाता है</string>
<string name="Slash_Tableflip_Description">(╯°□°)╯︵ ┻━┻ दिखाता है</string>
<string name="Slash_TableUnflip_Description">┬─┬ ノ( ゜-゜ノ) दिखाता है</string>
<string name="Create_A_New_Channel">एक नया चैनल बनाएं</string>
<string name="Show_the_keyboard_shortcut_list">कीबोर्ड शॉर्टकट सूची दिखाएं</string>
<string name="Invite_user_to_join_channel_all_from">इस चैनल में शामिल होने के लिए [#channel] से सभी उपयोगकर्ताओं को आमंत्रित करें</string>
<string name="Invite_user_to_join_channel_all_to">[# चैनल] में शामिल होने के लिए सभी उपयोगकर्ताओं को इस चैनल पर आमंत्रित करें</string>
<string name="Archive">संग्रहित करें</string>
<string name="Remove_someone_from_room">रूम से किसी को निकालें</string>
<string name="Leave_the_current_channel">मौजूदा चैनल को छोड़ दें</string>
<string name="Displays_action_text">Displays action text</string>
<string name="Direct_message_someone">किसी को प्रत्यक्ष संदेश भेजें</string>
<string name="Mute_someone_in_room">रूम में किसी को म्यूट करें</string>
<string name="Unmute_someone_in_room">रूम में किसी को अनम्यूट करें</string>
<string name="Invite_user_to_join_channel">इस चैनल में शामिल होने के लिए एक उपयोगकर्ता को आमंत्रित करें</string>
<string name="Unarchive">असंग्रहित करें</string>
<string name="Join_the_given_channel">दिए गए चैनल से जुड़ें</string>
<string name="Guggy_Command_Description">दिए गए पाठ पर आधारित gif उत्पन्न करता</string>
<string name="Slash_Topic_Description">विषय सेट करें</string>
<!-- Emoji message-->
<string name="msg_no_recent_emoji"> कोई नया इमोजी नहीं</string>
<!-- Sorting and grouping-->
<string name="menu_chatroom_sort">क्रम</string>
<string name="dialog_sort_title">द्वारा सॉर्ट करें</string>
<string name="dialog_sort_by_alphabet">वर्णानुक्रम</string>
<string name="dialog_sort_by_activity">गतिविधि</string>
<string name="dialog_group_by_type">प्रकार के आधार पर समूह</string>
<string name="dialog_group_favourites">पसंदीदा समूह</string>
<string name="chatroom_header">हैडर</string>
<!--ChatRooms Headers-->
<string name="header_channel">चैनलों</string>
<string name="header_private_groups">निजी समूहों</string>
<string name="header_direct_messages">प्रत्यक्ष संदेश</string>
<string name="header_live_chats">Live Chats</string>
<string name="header_unknown">अज्ञात</string>
</resources>
\ No newline at end of file
......@@ -25,7 +25,7 @@
<string name="action_files">Arquivos</string>
<string name="action_confirm_password">Confirme a nova senha</string>
<string name="action_join_chat">Entrar no Chat</string>
<string name="action_add_account">Adicionar Conta</string>
<!-- Settings List -->
<string-array name="settings_actions">
......@@ -69,6 +69,18 @@
<string name="msg_new_password">Informe a nova senha</string>
<string name="msg_confirm_password">Confirme a nova senha</string>
<string name="msg_unread_messages">Mensagens não lidas</string>
<string name="msg_preview_video">Vídeo</string>
<string name="msg_preview_audio">Audio</string>
<string name="msg_preview_photo">Foto</string>
<string name="msg_no_messages_yet">Nenhuma mensagem ainda</string>
<string name="msg_requires_username">Nome de usuário requerido: Por favor, crie um nome de usuário através da versão web e volte para fazer login.</string>
<string name="msg_ok">OK</string>
<string name="msg_ver_not_recommended">
Parece que a versão do seu servidor está abaixo da recomendada %1$s.\nVocê ainda assim pode logar e continuar mas podem ocorrer alguns problemas inesperados.
</string>
<string name="msg_ver_not_minimum">
Parece que a versão do seu servidor está abaixo da mínima requerida %1$s.\nPor favor, atualize seus servidores antes de continuar!
</string>
<!-- System messages -->
<string name="message_room_name_changed">Nome da sala alterado para: %1$s por %2$s</string>
......@@ -115,6 +127,10 @@
<string name="status_disconnecting">desconectando</string>
<string name="status_waiting">conectando em %d segundos</string>
<!--Suggestions-->
<string name="suggest_all_description">Notifica todos nesta sala</string>
<string name="suggest_here_description">Notifica usuários ativos nesta sala</string>
<!-- Slash Commands -->
<string name="Slash_Gimme_Description">Exibir ༼ つ ◕_◕ ༽つ antes de sua mensagem</string>
<string name="Slash_LennyFace_Description">Exibir ( ͡° ͜ʖ ͡°) depois de sua mensagem</string>
......@@ -140,4 +156,20 @@
<!-- Emoji message-->
<string name="msg_no_recent_emoji">Nenhum emoji recente</string>
<!-- Sorting and grouping-->
<string name="menu_chatroom_sort">Ordenar</string>
<string name="dialog_sort_title">Ordenar por</string>
<string name="dialog_sort_by_alphabet">Alfabeticamente</string>
<string name="dialog_sort_by_activity">Atividade</string>
<string name="dialog_group_by_type">Agrupar por tipo</string>
<string name="dialog_group_favourites">Grupos favoritos</string>
<string name="chatroom_header">Cabeçalho</string>
<!--ChatRooms Headers-->
<string name="header_channel">Canais</string>
<string name="header_private_groups">Grupos Privados</string>
<string name="header_direct_messages">Mensagens diretas</string>
<string name="header_live_chats">Live Chats</string>
<string name="header_unknown">Desconhecido</string>
</resources>
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#15293F</color>
<!-- Main colors -->
<color name="colorPrimary">#FF303030</color> <!-- Material Grey 850 -->
......@@ -17,6 +16,8 @@
<color name="colorUserStatusAway">#FDD236</color>
<color name="colorUserStatusOffline">#d9d9d9</color>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="colorDrawableTintGrey">#9FA2A8</color>
<color name="colorDividerMessageComposer">#D8D8D8</color>
......@@ -34,6 +35,8 @@
<color name="actionMenuColor">#FF727272</color>
<color name="whitesmoke">#FFf1f1f1</color>
<color name="translucent_white">#70F1F1F1</color>
<color name="colorEmojiIcon">#FF767676</color>
<!-- Suggestions -->
......
......@@ -35,5 +35,6 @@
<!-- Autocomplete Popup -->
<dimen name="popup_max_height">150dp</dimen>
<dimen name="suggestions_box_max_height">250dp</dimen>
<dimen name="nav_header_height">160dp</dimen>
</resources>
\ No newline at end of file
......@@ -26,6 +26,7 @@
<string name="action_files">Files</string>
<string name="action_confirm_password">Confirm Password Change</string>
<string name="action_join_chat">Join Chat</string>
<string name="action_add_account">Add Account</string>
<!-- Settings List -->
<string-array name="settings_actions">
......@@ -70,6 +71,17 @@
<string name="msg_new_password">Enter New Password</string>
<string name="msg_confirm_password">Confirm New Password</string>
<string name="msg_unread_messages">Unread messages</string>
<string name="msg_preview_video">Video</string>
<string name="msg_preview_audio">Audio</string>
<string name="msg_preview_photo">Photo</string>
<string name="msg_no_messages_yet">No messages yet</string>
<string name="msg_requires_username">Username required: Please, create an username through the web version and come back to log in.</string>
<string name="msg_ok">OK</string>
<string name="msg_ver_not_recommended">
Looks like your server version is below the recommended version %1$s.\nYou can still login but you may experience unexpected behaviors.</string>
<string name="msg_ver_not_minimum">
Looks like your server version is below the minimum required version %1$s.\nPlease upgrade your server to login!
</string>
<!-- System messages -->
<string name="message_room_name_changed">Room name changed to: %1$s by %2$s</string>
......@@ -116,6 +128,10 @@
<string name="status_disconnecting">disconnecting</string>
<string name="status_waiting">connecting in %d seconds</string>
<!--Suggestions-->
<string name="suggest_all_description">Notify all in this room</string>
<string name="suggest_here_description">Notify active users in this room</string>
<!-- Slash Commands -->
<string name="Slash_Gimme_Description">Displays ༼ つ ◕_◕ ༽つ before your message</string>
<string name="Slash_LennyFace_Description">Displays ( ͡° ͜ʖ ͡°) after your message</string>
......@@ -141,4 +157,20 @@
<!-- Emoji message-->
<string name="msg_no_recent_emoji">No recent emoji</string>
<!-- Sorting and grouping-->
<string name="menu_chatroom_sort">Sort</string>
<string name="dialog_sort_title">Sort by</string>
<string name="dialog_sort_by_alphabet">Alphabetical</string>
<string name="dialog_sort_by_activity">Activity</string>
<string name="dialog_group_by_type">Group by type</string>
<string name="dialog_group_favourites">Group favourites</string>
<string name="chatroom_header">Header</string>
<!--ChatRooms Headers-->
<string name="header_channel">Channels</string>
<string name="header_private_groups">Private Groups</string>
<string name="header_direct_messages">Direct Messages</string>
<string name="header_live_chats">Live Chats</string>
<string name="header_unknown">Unknown</string>
</resources>
\ No newline at end of file
......@@ -71,6 +71,10 @@
<item name="android:paddingStart">@dimen/edit_text_margin</item>
</style>
<style name="ChatRooms.Header" parent="TextAppearance.AppCompat.Headline">
<item name="android:textSize">16sp</item>
</style>
<style name="ChatRoom.Name.TextView" parent="TextAppearance.AppCompat.Title">
<item name="android:ellipsize">end</item>
<item name="android:maxLines">1</item>
......@@ -88,6 +92,10 @@
<item name="android:textColor">@color/colorPrimaryText</item>
</style>
<style name="Message.Quote.TextView" parent="Message.TextView">
<item name="android:textColor">@color/colorPrimaryText</item>
</style>
<style name="Timestamp.TextView" parent="TextAppearance.AppCompat.Caption">
<item name="android:textSize">10sp</item>
</style>
......
......@@ -10,11 +10,12 @@ buildscript {
}
dependencies {
classpath "com.android.tools.build:gradle:3.0.1"
classpath 'com.android.tools.build:gradle:3.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:${versions.dokka}"
classpath 'com.google.gms:google-services:3.2.0'
classpath 'io.fabric.tools:gradle:1.25.1'
classpath "io.realm:realm-gradle-plugin:5.0.0"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
......
......@@ -4,14 +4,14 @@ ext {
compileSdk : 27,
targetSdk : 27,
buildTools : '27.0.3',
kotlin : '1.2.30',
coroutine : '0.22',
dokka : '0.9.15',
kotlin : '1.2.31',
coroutine : '0.22.5',
dokka : '0.9.16',
// Main dependencies
support : '27.0.2',
support : '27.1.0',
constraintLayout : '1.0.2',
androidKtx : '0.1',
androidKtx : '0.2',
dagger : '2.14.1',
exoPlayer : '2.6.0',
playServices : '11.8.0',
......@@ -19,12 +19,12 @@ ext {
rxKotlin : '2.2.0',
rxAndroid : '2.0.2',
moshi : '1.6.0-SNAPSHOT',
okhttp : '3.9.1',
timber : '4.6.1',
okhttp : '3.10.0',
timber : '4.7.0',
threeTenABP : '1.0.5',
rxBinding : '2.0.0',
fresco : '1.8.1',
kotshi : '0.3.0',
kotshi : '1.0.2',
frescoImageViewer : '0.5.1',
markwon : '1.0.3',
sheetMenu : '1.3.3',
......@@ -37,67 +37,68 @@ ext {
expresso : '3.0.1',
mockito : '2.10.0'
]
libraries = [
kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jre8:${versions.kotlin}",
coroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutine}",
coroutinesAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutine}",
appCompat : "com.android.support:appcompat-v7:${versions.support}",
annotations : "com.android.support:support-annotations:${versions.support}",
recyclerview : "com.android.support:recyclerview-v7:${versions.support}",
design : "com.android.support:design:${versions.support}",
constraintLayout : "com.android.support.constraint:constraint-layout:${versions.constraintLayout}",
cardView : "com.android.support:cardview-v7:${versions.support}",
flexbox : "com.google.android:flexbox:${versions.flexbox}",
androidKtx : "androidx.core:core-ktx:${versions.androidKtx}",
dagger : "com.google.dagger:dagger:${versions.dagger}",
daggerSupport : "com.google.dagger:dagger-android-support:${versions.dagger}",
daggerProcessor : "com.google.dagger:dagger-compiler:${versions.dagger}",
daggerAndroidApt : "com.google.dagger:dagger-android-processor:${versions.dagger}",
playServicesGcm : "com.google.android.gms:play-services-gcm:${versions.playServices}",
exoPlayer : "com.google.android.exoplayer:exoplayer:${versions.exoPlayer}",
room : "android.arch.persistence.room:runtime:${versions.room}",
roomProcessor : "android.arch.persistence.room:compiler:${versions.room}",
roomRxjava : "android.arch.persistence.room:rxjava2:${versions.room}",
rxKotlin : "io.reactivex.rxjava2:rxkotlin:${versions.rxKotlin}",
rxAndroid : "io.reactivex.rxjava2:rxandroid:${versions.rxAndroid}",
moshi : "com.squareup.moshi:moshi:${versions.moshi}",
moshiKotlin : "com.squareup.moshi:moshi-kotlin:${versions.moshi}",
okhttp : "com.squareup.okhttp3:okhttp:${versions.okhttp}",
okhttpLogger : "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}",
timber : "com.jakewharton.timber:timber:${versions.timber}",
threeTenABP : "com.jakewharton.threetenabp:threetenabp:${versions.threeTenABP}",
rxBinding : "com.jakewharton.rxbinding2:rxbinding-kotlin:${versions.rxBinding}",
fresco : "com.facebook.fresco:fresco:${versions.fresco}",
frescoOkHttp : "com.facebook.fresco:imagepipeline-okhttp3:${versions.fresco}",
frescoAnimatedGif : "com.facebook.fresco:animated-gif:${versions.fresco}",
frescoWebP : "com.facebook.fresco:webpsupport:${versions.fresco}",
frescoAnimatedWebP : "com.facebook.fresco:animated-webp:${versions.fresco}",
kotshiApi : "se.ansman.kotshi:api:${versions.kotshi}",
kotshiCompiler : "se.ansman.kotshi:compiler:${versions.kotshi}",
frescoImageViewer : "com.github.luciofm:FrescoImageViewer:${versions.frescoImageViewer}",
markwon : "ru.noties:markwon:${versions.markwon}",
markwonImageLoader : "ru.noties:markwon-image-loader:${versions.markwon}",
sheetMenu : "com.github.whalemare:sheetmenu:${versions.sheetMenu}",
aVLoadingIndicatorView : "com.wang.avi:library:${versions.aVLoadingIndicatorView}",
// For testing
junit : "junit:junit:$versions.junit",
expressoCore : "com.android.support.test.espresso:espresso-core:${versions.expresso}",
roomTest : "android.arch.persistence.room:testing:${versions.room}",
truth : "com.google.truth:truth:$versions.truth",
kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jre8:${versions.kotlin}",
coroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutine}",
coroutinesAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutine}",
appCompat : "com.android.support:appcompat-v7:${versions.support}",
annotations : "com.android.support:support-annotations:${versions.support}",
recyclerview : "com.android.support:recyclerview-v7:${versions.support}",
design : "com.android.support:design:${versions.support}",
constraintLayout : "com.android.support.constraint:constraint-layout:${versions.constraintLayout}",
cardView : "com.android.support:cardview-v7:${versions.support}",
flexbox : "com.google.android:flexbox:${versions.flexbox}",
customTabs : "com.android.support:customtabs:${versions.support}",
androidKtx : "androidx.core:core-ktx:${versions.androidKtx}",
dagger : "com.google.dagger:dagger:${versions.dagger}",
daggerSupport : "com.google.dagger:dagger-android-support:${versions.dagger}",
daggerProcessor : "com.google.dagger:dagger-compiler:${versions.dagger}",
daggerAndroidApt : "com.google.dagger:dagger-android-processor:${versions.dagger}",
playServicesGcm : "com.google.android.gms:play-services-gcm:${versions.playServices}",
exoPlayer : "com.google.android.exoplayer:exoplayer:${versions.exoPlayer}",
room : "android.arch.persistence.room:runtime:${versions.room}",
roomProcessor : "android.arch.persistence.room:compiler:${versions.room}",
roomRxjava : "android.arch.persistence.room:rxjava2:${versions.room}",
rxKotlin : "io.reactivex.rxjava2:rxkotlin:${versions.rxKotlin}",
rxAndroid : "io.reactivex.rxjava2:rxandroid:${versions.rxAndroid}",
moshi : "com.squareup.moshi:moshi:${versions.moshi}",
moshiKotlin : "com.squareup.moshi:moshi-kotlin:${versions.moshi}",
okhttp : "com.squareup.okhttp3:okhttp:${versions.okhttp}",
okhttpLogger : "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}",
timber : "com.jakewharton.timber:timber:${versions.timber}",
threeTenABP : "com.jakewharton.threetenabp:threetenabp:${versions.threeTenABP}",
rxBinding : "com.jakewharton.rxbinding2:rxbinding-kotlin:${versions.rxBinding}",
fresco : "com.facebook.fresco:fresco:${versions.fresco}",
frescoOkHttp : "com.facebook.fresco:imagepipeline-okhttp3:${versions.fresco}",
frescoAnimatedGif : "com.facebook.fresco:animated-gif:${versions.fresco}",
frescoWebP : "com.facebook.fresco:webpsupport:${versions.fresco}",
frescoAnimatedWebP : "com.facebook.fresco:animated-webp:${versions.fresco}",
kotshiApi : "se.ansman.kotshi:api:${versions.kotshi}",
kotshiCompiler : "se.ansman.kotshi:compiler:${versions.kotshi}",
frescoImageViewer : "com.github.luciofm:FrescoImageViewer:${versions.frescoImageViewer}",
markwon : "ru.noties:markwon:${versions.markwon}",
markwonImageLoader : "ru.noties:markwon-image-loader:${versions.markwon}",
sheetMenu : "com.github.whalemare:sheetmenu:${versions.sheetMenu}",
aVLoadingIndicatorView : "com.wang.avi:library:${versions.aVLoadingIndicatorView}",
// For testing
junit : "junit:junit:$versions.junit",
expressoCore : "com.android.support.test.espresso:espresso-core:${versions.expresso}",
roomTest : "android.arch.persistence.room:testing:${versions.room}",
truth : "com.google.truth:truth:$versions.truth",
]
}
\ No newline at end of file
......@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment