Select Git revision
-
Janne Mareike Koschinski authoredJanne Mareike Koschinski authored
QuasselService.kt NaN GiB
/*
* Quasseldroid - Quassel client for Android
*
* Copyright (c) 2020 Janne Mareike Koschinski
* Copyright (c) 2020 The Quassel Project
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 3 as published
* by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package de.kuschku.quasseldroid.service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.text.SpannableString
import androidx.core.app.RemoteInput
import androidx.lifecycle.Observer
import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork
import de.kuschku.libquassel.connection.ConnectionState
import de.kuschku.libquassel.connection.HostnameVerifier
import de.kuschku.libquassel.connection.SocketAddress
import de.kuschku.libquassel.protocol.*
import de.kuschku.libquassel.quassel.BufferInfo
import de.kuschku.libquassel.quassel.QuasselFeatures
import de.kuschku.libquassel.quassel.syncables.interfaces.IAliasManager
import de.kuschku.libquassel.session.ISession
import de.kuschku.libquassel.session.Session
import de.kuschku.libquassel.session.SessionManager
import de.kuschku.libquassel.session.manager.ConnectionInfo
import de.kuschku.libquassel.util.compatibility.LoggingHandler.Companion.log
import de.kuschku.libquassel.util.compatibility.LoggingHandler.LogLevel.INFO
import de.kuschku.libquassel.util.helper.clampOf
import de.kuschku.libquassel.util.helper.value
import de.kuschku.malheur.CrashHandler
import de.kuschku.quasseldroid.Backend
import de.kuschku.quasseldroid.BuildConfig
import de.kuschku.quasseldroid.Keys
import de.kuschku.quasseldroid.R
import de.kuschku.quasseldroid.defaults.Defaults
import de.kuschku.quasseldroid.persistence.dao.*
import de.kuschku.quasseldroid.persistence.db.AccountDatabase
import de.kuschku.quasseldroid.persistence.db.QuasselDatabase
import de.kuschku.quasseldroid.persistence.util.AccountId
import de.kuschku.quasseldroid.persistence.util.QuasselBacklogStorage
import de.kuschku.quasseldroid.settings.ConnectionSettings
import de.kuschku.quasseldroid.settings.NotificationSettings
import de.kuschku.quasseldroid.settings.Settings
import de.kuschku.quasseldroid.ssl.QuasselHostnameVerifier
import de.kuschku.quasseldroid.ssl.QuasselTrustManager
import de.kuschku.quasseldroid.ssl.custom.QuasselCertificateManager
import de.kuschku.quasseldroid.ssl.custom.QuasselHostnameManager
import de.kuschku.quasseldroid.util.backport.DaggerLifecycleService
import de.kuschku.quasseldroid.util.compatibility.AndroidHandlerService
import de.kuschku.quasseldroid.util.helper.*
import de.kuschku.quasseldroid.util.irc.format.IrcFormatSerializer
import de.kuschku.quasseldroid.util.ui.LocaleHelper
import io.reactivex.subjects.BehaviorSubject
import org.threeten.bp.Instant
import javax.inject.Inject
import javax.net.ssl.X509TrustManager
class QuasselService : DaggerLifecycleService(),
SharedPreferences.OnSharedPreferenceChangeListener {
@Inject
lateinit var connectionSettings: ConnectionSettings
@Inject
lateinit var notificationSettings: NotificationSettings
private lateinit var translatedLocale: Context
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
update()
translatedLocale = LocaleHelper.setLocale(this)
notificationBackend.updateSettings()
}
private fun update() {
this.notificationSettings = Settings.notification(this)
val connectionSettings = Settings.connection(this)
if (this.connectionSettings.showNotification != connectionSettings.showNotification) {
updateNotificationStatus(this.progress)
}
this.connectionSettings = connectionSettings
val (accountId, reconnect) = this.sharedPreferences(Keys.Status.NAME, Context.MODE_PRIVATE) {
Pair(
AccountId(getLong(Keys.Status.selectedAccount, -1)),
getBoolean(Keys.Status.reconnect, false)
)
}
if (this.accountId != accountId || this.reconnect != reconnect) {
this.accountId = accountId
this.reconnect = reconnect
handlerService.backend {
val account = if (accountId.isValidId() && reconnect) {
accountDatabase.accounts().findById(accountId)
} else {
null
}
if (account == null) {
backendImplementation.disconnect(true)
stopSelf()
} else {
backendImplementation.autoConnect(
connectionInfo = Backend.ConnectionInfo(
SocketAddress(account.host, account.port),
account.user,
account.pass,
account.requireSsl,
true
)
)
}
}
} else if (!accountId.isValidId() || !reconnect) {
handlerService.backend {
backendImplementation.disconnect(true)
stopSelf()
}
}
}
private var accountId: AccountId = AccountId(-1)
set(value) {
field = value
liveAccountId.onNext(value)
}
private var liveAccountId = BehaviorSubject.createDefault(AccountId(-1L))
private var reconnect: Boolean = false
@Inject
lateinit var notificationManager: QuasseldroidNotificationManager
@Inject
lateinit var notificationBackend: QuasselNotificationBackend
@Inject
lateinit var ircFormatSerializer: IrcFormatSerializer
private var notificationHandle: QuasseldroidNotificationManager.Handle? = null
private var progress = Triple(ConnectionState.DISCONNECTED, 0, 0)
private fun updateNotificationStatus(rawProgress: Triple<ConnectionState, Int, Int>) {
if (connectionSettings.showNotification) {
val notificationHandle = notificationManager.notificationBackground()
this.notificationHandle = notificationHandle
updateNotification(notificationHandle, rawProgress)
startForeground(notificationHandle.id, notificationHandle.builder.build())
} else {
this.notificationHandle = null
stopForeground(true)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val result = super.onStartCommand(intent, flags, startId)
handleIntent(intent)
return result
}
private fun handleIntent(intent: Intent?) {
if (intent == null) return
if (intent.getBooleanExtra("disconnect", false)) {
sharedPreferences(Keys.Status.NAME, Context.MODE_PRIVATE) {
editApply {
putBoolean(Keys.Status.reconnect, false)
}
}
}
val bufferId = BufferId(intent.getIntExtra("bufferId", -1))
val inputResults = RemoteInput.getResultsFromIntent(intent)?.getCharSequence("reply_content")
if (inputResults != null && bufferId.isValidId()) {
if (inputResults.isNotBlank()) {
val lines = inputResults.lineSequence().map {
it.toString() to ircFormatSerializer.toEscapeCodes(SpannableString(it))
}
sessionManager.connectedSession.value?.let { session ->
session.bufferSyncer.bufferInfo(bufferId)?.also { bufferInfo ->
val output = mutableListOf<IAliasManager.Command>()
for ((_, formatted) in lines) {
session.aliasManager.processInput(bufferInfo, formatted, output)
}
for (command in output) {
session.rpcHandler.sendInput(command.buffer, command.message)
}
}
handlerService.backend {
notificationBackend.showConnectedNotifications()
}
}
}
} else {
val clearMessageId = MsgId(intent.getLongExtra("mark_read_message", -1))
if (bufferId.isValidId() && clearMessageId.isValidId()) {
sessionManager.connectedSession.value?.bufferSyncer?.requestSetLastSeenMsg(bufferId,
clearMessageId)
sessionManager.connectedSession.value?.bufferSyncer?.requestMarkBufferAsRead(bufferId)
}
val hideMessageId = MsgId(intent.getLongExtra("hide_message", -1))
if (bufferId.isValidId() && hideMessageId.isValidId()) {
if (notificationSettings.markReadOnSwipe) {
sessionManager.connectedSession.value?.bufferSyncer?.requestSetLastSeenMsg(bufferId,
hideMessageId)
sessionManager.connectedSession.value?.bufferSyncer?.requestMarkBufferAsRead(bufferId)
} else {
handlerService.backend {
database.notifications().markHidden(bufferId, hideMessageId)
}
}
}
}
}
private fun updateNotification(handle: QuasseldroidNotificationManager.Handle,
rawProgress: Triple<ConnectionState, Int, Int>) {
val (state, progress, max) = rawProgress
when (state) {
ConnectionState.DISCONNECTED -> {
handle.builder.setContentTitle(getString(R.string.label_status_disconnected))
handle.builder.setProgress(0, 0, true)
}
ConnectionState.CONNECTING -> {
handle.builder.setContentTitle(getString(R.string.label_status_connecting))
handle.builder.setProgress(max, progress, true)
}
ConnectionState.HANDSHAKE -> {
handle.builder.setContentTitle(getString(R.string.label_status_handshake))
handle.builder.setProgress(max, progress, true)
}
ConnectionState.INIT -> {
handle.builder.setContentTitle(getString(R.string.label_status_init))
// Show indeterminate when no progress has been made yet
handle.builder.setProgress(max, progress, progress == 0 || max == 0)
}
ConnectionState.CONNECTED -> {
handle.builder.setContentTitle(getString(R.string.label_status_connected))
handle.builder.setProgress(0, 0, false)
}
ConnectionState.CLOSED -> {
handle.builder.setContentTitle(getString(R.string.label_status_closed))
handle.builder.setProgress(0, 0, false)
}
}
}
private lateinit var sessionManager: SessionManager
private lateinit var clientData: ClientData
private lateinit var trustManager: X509TrustManager
private lateinit var hostnameVerifier: HostnameVerifier
private lateinit var certificateManager: QuasselCertificateManager
class BackendImplementation : Backend {
var service: QuasselService? = null
override fun updateUserDataAndLogin(user: String, pass: String) {
service?.apply {
accountDatabase.accounts().findById(accountId)?.let { old ->
accountDatabase.accounts().save(old.copy(user = user, pass = pass))
sessionManager.login(user, pass)
}
}
}
override fun sessionManager() = service?.sessionManager
override fun autoConnect(
ignoreConnectionState: Boolean,
ignoreSetting: Boolean,
ignoreErrors: Boolean,
connectionInfo: Backend.ConnectionInfo?
) {
service?.apply {
if (connectionInfo != null) {
sessionManager.autoConnect(
ignoreConnectionState,
ignoreSetting,
ignoreErrors,
ConnectionInfo(
clientData = clientData,
trustManager = trustManager,
hostnameVerifier = hostnameVerifier,
address = connectionInfo.address,
userData = Pair(connectionInfo.username,
connectionInfo.password),
requireSsl = connectionInfo.requireSsl,
shouldReconnect = connectionInfo.shouldReconnect
)
)
} else {
sessionManager.autoConnect(ignoreConnectionState, ignoreSetting, ignoreErrors)
}
}
}
override fun disconnect(forever: Boolean) {
service?.apply {
sessionManager.disconnect(forever)
}
}
override fun requestConnectNewNetwork() {
service?.apply {
sessionManager.connectedSession.flatMap(ISession::liveNetworkAdded).firstElement().flatMap { id ->
sessionManager.connectedSession.flatMap(ISession::liveNetworks)
.map { it[id] }
.flatMap { network ->
network.liveInitialized
.filter { it }
.map { network }
}.firstElement()
}.toLiveData().observe(this, Observer {
it?.requestConnect()
})
}
}
override fun setCurrentBuffer(id: BufferId) {
service?.currentBuffer?.onNext(id)
}
}
private val backendImplementation = BackendImplementation()
private val handlerService = AndroidHandlerService()
private val asyncBackend = AsyncBackend(handlerService, backendImplementation)
@Inject
lateinit var database: QuasselDatabase
@Inject
lateinit var accountDatabase: AccountDatabase
lateinit var currentBuffer: BehaviorSubject<BufferId>
private fun disconnectFromCore() {
getSharedPreferences(Keys.Status.NAME, Context.MODE_PRIVATE).editCommit {
putBoolean(Keys.Status.reconnect, false)
}
}
override fun onCreate() {
super.onCreate()
backendImplementation.service = this
asyncBackend.setDisconnectCallback(::stopSelf)
translatedLocale = LocaleHelper.setLocale(this)
certificateManager = QuasselCertificateManager(database.validityWhitelist())
hostnameVerifier = QuasselHostnameVerifier(QuasselHostnameManager(database.hostnameWhitelist()))
trustManager = QuasselTrustManager(certificateManager)
val backlogStorage = QuasselBacklogStorage(database)
currentBuffer = backlogStorage.currentBuffer
sessionManager = SessionManager(
ISession.NULL,
backlogStorage,
notificationBackend,
handlerService,
::AndroidHeartBeatRunner,
CrashHandler::handle
)
sessionManager.setDisconnectFromCore(::disconnectFromCore)
sessionManager.setInitCallback(::initCallback)
clientData = ClientData(
identifier = "${resources.getString(R.string.app_name)} ${BuildConfig.FANCY_VERSION_NAME}",
buildDate = Instant.ofEpochSecond(BuildConfig.GIT_COMMIT_DATE),
clientFeatures = QuasselFeatures.all(),
protocolFeatures = Protocol_Features.of(
Protocol_Feature.Compression,
Protocol_Feature.TLS
),
supportedProtocols = listOf(Protocol.Datastream)
)
sessionManager.connectionProgress.toLiveData().observe(this, Observer {
if (this.progress.first != it?.first && it?.first == ConnectionState.CONNECTED) {
handlerService.backend {
database.message().clearMessages()
}
}
val rawProgress = it ?: Triple(ConnectionState.DISCONNECTED, 0, 0)
this.progress = rawProgress
val handle = this.notificationHandle
if (handle != null) {
updateNotification(handle, rawProgress)
notificationManager.notify(handle)
}
})
ReactiveNetwork
.observeNetworkConnectivity(applicationContext)
.toLiveData()
.observe(this, Observer { connectivity ->
if (!connectionSettings.ignoreNetworkChanges) {
log(INFO, "QuasselService", "Connectivity changed: $connectivity")
handlerService.backend {
log(INFO, "QuasselService", "Reconnect triggered: Network changed")
sessionManager.autoConnect(
ignoreConnectionState = true,
ignoreSetting = true
)
}
}
})
sessionManager.state
.distinctUntilChanged()
.toLiveData()
.observe(
this, Observer {
handlerService.backend {
if (it == ConnectionState.HANDSHAKE) {
backoff = BACKOFF_MIN
}
if (it == ConnectionState.CLOSED) {
scheduleReconnect()
notificationBackend.showDisconnectedNotifications()
}
}
})
sharedPreferences(Keys.Status.NAME, Context.MODE_PRIVATE) {
registerOnSharedPreferenceChangeListener(this@QuasselService)
}
sharedPreferences {
registerOnSharedPreferenceChangeListener(this@QuasselService)
}
notificationManager.init()
update()
updateNotificationStatus(this.progress)
}
private var backoff = BACKOFF_MIN
private var scheduled = false
private fun scheduleReconnect() {
if (!scheduled && sessionManager.canAutoReconnect(ignoreSetting = true)) {
log(INFO, "QuasselService", "Reconnect: Scheduling backoff in ${backoff / 1_000} seconds")
scheduled = true
handlerService.backendDelayed(backoff) {
log(INFO, "QuasselService", "Reconnect: Scheduled backoff happened")
scheduled = false
backoff = clampOf(backoff * 2, BACKOFF_MIN, BACKOFF_MAX)
sessionManager.autoConnect(ignoreSetting = true)
}
}
}
override fun onDestroy() {
sharedPreferences(Keys.Status.NAME, Context.MODE_PRIVATE) {
unregisterOnSharedPreferenceChangeListener(this@QuasselService)
}
sharedPreferences {
unregisterOnSharedPreferenceChangeListener(this@QuasselService)
}
sessionManager.dispose()
asyncBackend.setDisconnectCallback(null)
backendImplementation.service = null
notificationHandle?.let { notificationManager.remove(it) }
super.onDestroy()
}
override fun onBind(intent: Intent?): QuasselBinder {
super.onBind(intent)
return QuasselBinder(asyncBackend)
}
private fun initCallback(session: Session) {
if (session.bufferViewManager.bufferViewConfigs().isEmpty()) {
session.bufferViewManager.requestCreateBufferView(
Defaults.bufferViewConfigInitial(translatedLocale).apply {
for (info in session.bufferSyncer.bufferInfos()) {
handleBuffer(info, session.bufferSyncer)
}
}.toVariantMap()
)
}
// Cleanup deleted buffers from cache
val buffers = session.bufferSyncer.bufferInfos().map(BufferInfo::bufferId)
val deletedBuffersMessage = database.message().buffers().toSet() - buffers
log(INFO, "QuasselService", "Buffers deleted from message storage: $deletedBuffersMessage")
for (deletedBuffer in deletedBuffersMessage) {
database.message().clearMessages(deletedBuffer.id)
}
val deletedBuffersFiltered = database.filtered().buffers(accountId).toSet() - buffers
log(INFO, "QuasselService", "Buffers deleted from filtered storage: $deletedBuffersFiltered")
for (deletedBuffer in deletedBuffersFiltered) {
database.filtered().clear(accountId, deletedBuffer)
}
val deletedBuffersNotifications = database.notifications().buffers().toSet() - buffers
log(INFO,
"QuasselService",
"Buffers deleted from notification storage: $deletedBuffersNotifications")
for (deletedBuffer in deletedBuffersNotifications) {
database.notifications().markReadNormal(deletedBuffer)
}
}
companion object {
// default backoff is 5 seconds
const val BACKOFF_MIN = 5_000L
// max is 30 minutes
const val BACKOFF_MAX = 1_800_000L
fun launch(
context: Context,
disconnect: Boolean? = null,
markRead: BufferId? = null,
markReadMessage: MsgId? = null,
hideMessage: MsgId? = null
): ComponentName? = context.startService(
intent(context, disconnect, markRead, markReadMessage, hideMessage)
)
fun intent(
context: Context,
disconnect: Boolean? = null,
bufferId: BufferId? = null,
markReadMessage: MsgId? = null,
hideMessage: MsgId? = null
) = Intent(context, QuasselService::class.java).apply {
if (disconnect != null) {
putExtra("disconnect", disconnect)
}
if (bufferId != null) {
putExtra("bufferId", bufferId.id)
}
if (markReadMessage != null) {
putExtra("mark_read_message", markReadMessage.id)
}
if (hideMessage != null) {
putExtra("hide_message", hideMessage.id)
}
}
}
}