package com.unciv.app import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.DEFAULT_VIBRATE import androidx.core.app.NotificationManagerCompat import androidx.work.* import com.badlogic.gdx.backends.android.AndroidApplication import com.unciv.logic.GameInfo import com.unciv.logic.GameSaver import com.unciv.models.metadata.GameSettings import com.unciv.logic.multiplayer.OnlineMultiplayer import java.io.FileNotFoundException import java.io.PrintWriter import java.io.StringWriter import java.io.Writer import java.util.* import java.util.concurrent.TimeUnit class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { companion object { const val WORK_TAG = "UNCIV_MULTIPLAYER_TURN_CHECKER_WORKER" const val CLIPBOARD_EXTRA = "CLIPBOARD_STRING" const val NOTIFICATION_ID_SERVICE = 1 const val NOTIFICATION_ID_INFO = 2 // Notification Channels can't be modified after creation. // Therefore Unciv needs to create new ones and delete previously used ones. // Add old channel names here when replacing them with new ones below. private val HISTORIC_NOTIFICATION_CHANNELS = arrayOf("UNCIV_NOTIFICATION_CHANNEL_SERVICE") private const val NOTIFICATION_CHANNEL_ID_INFO = "UNCIV_NOTIFICATION_CHANNEL_INFO" private const val NOTIFICATION_CHANNEL_ID_SERVICE = "UNCIV_NOTIFICATION_CHANNEL_SERVICE_02" private const val FAIL_COUNT = "FAIL_COUNT" private const val GAME_ID = "GAME_ID" private const val GAME_NAME = "GAME_NAME" private const val USER_ID = "USER_ID" private const val CONFIGURED_DELAY = "CONFIGURED_DELAY" private const val PERSISTENT_NOTIFICATION_ENABLED = "PERSISTENT_NOTIFICATION_ENABLED" private const val FILE_STORAGE = "FILE_STORAGE" fun enqueue(appContext: Context, delayInMinutes: Int, inputData: Data) { val constraints = Constraints.Builder() // If no internet is available, worker waits before becoming active. .setRequiredNetworkType(NetworkType.CONNECTED) .build() val checkTurnWork = OneTimeWorkRequestBuilder() .setConstraints(constraints) .setInitialDelay(delayInMinutes.toLong(), TimeUnit.MINUTES) .addTag(WORK_TAG) .setInputData(inputData) .build() WorkManager.getInstance(appContext).enqueue(checkTurnWork) } /** * Notification Channel for 'It's your turn' and error notifications. * * This code is necessary for API level >= 26 * API level < 26 does not support Notification Channels * For more infos: https://developer.android.com/training/notify-user/channels.html#CreateChannel */ fun createNotificationChannelInfo(appContext: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val name = appContext.resources.getString(R.string.Notify_ChannelInfo_Short) val descriptionText = appContext.resources.getString(R.string.Notify_ChannelInfo_Long) val importance = NotificationManager.IMPORTANCE_HIGH val mChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID_INFO, name, importance) mChannel.description = descriptionText mChannel.setShowBadge(true) mChannel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(mChannel) } /** * Notification Channel for persistent service notification. * * This code is necessary for API level >= 26 * API level < 26 does not support Notification Channels * For more infos: https://developer.android.com/training/notify-user/channels.html#CreateChannel */ fun createNotificationChannelService(appContext: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val name = appContext.resources.getString(R.string.Notify_ChannelService_Short) val descriptionText = appContext.resources.getString(R.string.Notify_ChannelService_Long) val importance = NotificationManager.IMPORTANCE_MIN val mChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID_SERVICE, name, importance) mChannel.setShowBadge(false) mChannel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC mChannel.description = descriptionText val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(mChannel) } /** * The persistent notification is purely for informational reasons. * It is not technically necessary for the Worker, since it is not a Service. */ fun showPersistentNotification(appContext: Context, lastTimeChecked: String, checkPeriod: String) { val pendingIntent: PendingIntent = Intent(appContext, AndroidLauncher::class.java).let { notificationIntent -> PendingIntent.getActivity(appContext, 0, notificationIntent, 0) } val notification: NotificationCompat.Builder = NotificationCompat.Builder(appContext, NOTIFICATION_CHANNEL_ID_SERVICE) .setPriority(NotificationManagerCompat.IMPORTANCE_MIN) // it's only a status .setContentTitle(appContext.resources.getString(R.string.Notify_Persist_Short) + " " + lastTimeChecked) .setStyle(NotificationCompat.BigTextStyle() .bigText(appContext.resources.getString(R.string.Notify_Persist_Long_P1) + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P2) + " " + checkPeriod + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P3) + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P4))) .setSmallIcon(R.drawable.uncivnotification) .setContentIntent(pendingIntent) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setOnlyAlertOnce(true) .setOngoing(true) .setShowWhen(false) with(NotificationManagerCompat.from(appContext)) { notify(NOTIFICATION_ID_INFO, notification.build()) } } fun notifyUserAboutTurn(applicationContext: Context, game: Pair) { val intent = Intent(applicationContext, AndroidLauncher::class.java).apply { action = Intent.ACTION_VIEW data = Uri.parse("https://unciv.app/multiplayer?id=${game.second}") } val pendingIntent = PendingIntent.getActivity(applicationContext, 0, intent, 0) val contentTitle = applicationContext.resources.getString(R.string.Notify_YourTurn_Short) val notification: NotificationCompat.Builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID_INFO) .setPriority(NotificationManagerCompat.IMPORTANCE_HIGH) // people are waiting! .setContentTitle(contentTitle) .setContentText(applicationContext.resources.getString(R.string.Notify_YourTurn_Long).replace("[gameName]", game.first)) .setTicker(contentTitle) // without at least vibrate, some Android versions don't show a heads-up notification .setDefaults(DEFAULT_VIBRATE) .setLights(Color.YELLOW, 300, 100) .setSmallIcon(R.drawable.uncivnotification) .setContentIntent(pendingIntent) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setOngoing(false) with(NotificationManagerCompat.from(applicationContext)) { notify(NOTIFICATION_ID_INFO, notification.build()) } } fun startTurnChecker(applicationContext: Context, currentGameInfo: GameInfo, settings: GameSettings) { val gameFiles = GameSaver.getSaves(true) val gameIds = Array(gameFiles.count()) {""} val gameNames = Array(gameFiles.count()) {""} var count = 0 for (gameFile in gameFiles) { try { val gamePreview = GameSaver.loadGamePreviewFromFile(gameFile) if (gamePreview.turnNotification) { gameIds[count] = gamePreview.gameId gameNames[count] = gameFile.name() count++ } } catch (ex: Throwable) { //only loadGamePreviewFromFile can throw an exception //nothing will be added to the arrays if it fails //just skip one file } } if (currentGameInfo.currentPlayerCiv.playerId == settings.userId) { // May be useful to remind a player that he forgot to complete his turn. val gameIndex = gameIds.indexOf(currentGameInfo.gameId) // Of the turnNotification is OFF, this will be -1 since we never saved this game in the array // Or possibly reading the preview file returned an exception if (gameIndex!=-1) notifyUserAboutTurn(applicationContext, Pair(gameNames[gameIndex], gameIds[gameIndex])) } else { val inputData = workDataOf(Pair(FAIL_COUNT, 0), Pair(GAME_ID, gameIds), Pair(GAME_NAME, gameNames), Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.multiplayerTurnCheckerDelayInMinutes), Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.multiplayerTurnCheckerPersistentNotificationEnabled), Pair(FILE_STORAGE, settings.multiplayerServer)) if (settings.multiplayerTurnCheckerPersistentNotificationEnabled) { showPersistentNotification(applicationContext, "—", settings.multiplayerTurnCheckerDelayInMinutes.toString()) } // Initial check always happens after a minute, ignoring delay config. Better user experience this way. enqueue(applicationContext, 1, inputData) } } /** * Necessary for Multiplayer Turner Checker, starting with Android Oreo */ fun createNotificationChannels(appContext: Context) { createNotificationChannelInfo(appContext) createNotificationChannelService(appContext) destroyOldChannels(appContext) } /** * Notification Channels can't be modified after creation. * Therefore Unciv needs to create new ones and delete legacy ones. */ private fun destroyOldChannels(appContext: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val notificationManager = appContext.getSystemService(AndroidApplication.NOTIFICATION_SERVICE) as NotificationManager HISTORIC_NOTIFICATION_CHANNELS.forEach { if (null != notificationManager.getNotificationChannel(it)) { notificationManager.deleteNotificationChannel(it) } } } } override fun doWork(): Result { val showPersistNotific = inputData.getBoolean(PERSISTENT_NOTIFICATION_ENABLED, true) val configuredDelay = inputData.getInt(CONFIGURED_DELAY, 5) val fileStorage = inputData.getString(FILE_STORAGE) try { val gameIds = inputData.getStringArray(GAME_ID)!! val gameNames = inputData.getStringArray(GAME_NAME)!! var arrayIndex = 0 // We only want to notify the user or update persisted notification once but still want // to download all games to update the files so we save the first one we find var foundGame: Pair? = null for (gameId in gameIds){ //gameId could be an empty string if startTurnChecker fails to load all files if (gameId.isEmpty()) continue try { val gamePreview = OnlineMultiplayer(fileStorage).tryDownloadGamePreview(gameId) val currentTurnPlayer = gamePreview.getCivilization(gamePreview.currentPlayer) //Save game so MultiplayerScreen gets updated /* I received multiple reports regarding broken save games. All of them where missing a few thousand chars at the end of the save game. I assume this happened because the TurnCheckerWorker gets canceled by the AndroidLauncher while saves are getting saved right here. Lets hope it works with gamePreview as they are a lot smaller and faster to save */ GameSaver.saveGame(gamePreview, gameNames[arrayIndex]) if (currentTurnPlayer.playerId == inputData.getString(USER_ID)!! && foundGame == null) { foundGame = Pair(gameNames[arrayIndex], gameIds[arrayIndex]) } arrayIndex++ } catch (ex: FileNotFoundException){ // FileNotFoundException is thrown by OnlineMultiplayer().tryDownloadGamePreview(gameId) // and indicates that there is no game preview present for this game // in the dropbox so we should not check for this game in the future anymore val currentGamePreview = GameSaver.loadGamePreviewByName(gameNames[arrayIndex]) currentGamePreview.turnNotification = false GameSaver.saveGame(currentGamePreview, gameNames[arrayIndex]) } } if (foundGame != null){ notifyUserAboutTurn(applicationContext, foundGame) with(NotificationManagerCompat.from(applicationContext)) { cancel(NOTIFICATION_ID_SERVICE) } } else { if (showPersistNotific) { updatePersistentNotification(inputData) } // We have to reset the fail counter since no exception appeared val inputDataFailReset = Data.Builder().putAll(inputData).putInt(FAIL_COUNT, 0).build() enqueue(applicationContext, configuredDelay, inputDataFailReset) } } catch (ex: Exception) { val failCount = inputData.getInt(FAIL_COUNT, 0) if (failCount > 3) { showErrorNotification(getStackTraceString(ex)) with(NotificationManagerCompat.from(applicationContext)) { cancel(NOTIFICATION_ID_SERVICE) } return Result.failure() } else { if (showPersistNotific) { showPersistentNotification(applicationContext, applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay.toString()) } // If check fails, retry in one minute. // Makes sense, since checks only happen if Internet is available in principle. // Therefore a failure means either a problem with the GameInfo or with Dropbox. val inputDataFailIncrease = Data.Builder().putAll(inputData).putInt(FAIL_COUNT, failCount + 1).build() enqueue(applicationContext, 1, inputDataFailIncrease) } } catch (outOfMemory: OutOfMemoryError){ // no point in trying multiple times if this was an oom error return Result.failure() } return Result.success() } private fun getStackTraceString(ex: Exception): String { val writer: Writer = StringWriter() ex.printStackTrace(PrintWriter(writer)) return writer.toString() } private fun updatePersistentNotification(inputData: Data) { val cal = GregorianCalendar.getInstance() val hour = cal.get(GregorianCalendar.HOUR_OF_DAY).toString() var minute = cal.get(GregorianCalendar.MINUTE).toString() if (minute.length == 1) { minute = "0$minute" } val displayTime = "$hour:$minute" showPersistentNotification(applicationContext, displayTime, inputData.getInt(CONFIGURED_DELAY, 5).toString()) } private fun showErrorNotification(stackTraceString: String) { val pendingLaunchGameIntent: PendingIntent = Intent(applicationContext, AndroidLauncher::class.java).let { notificationIntent -> PendingIntent.getActivity(applicationContext, 0, notificationIntent, 0) } val pendingCopyClipboardIntent: PendingIntent = Intent(applicationContext, CopyToClipboardReceiver::class.java).putExtra(CLIPBOARD_EXTRA, stackTraceString) .let { notificationIntent -> PendingIntent.getBroadcast(applicationContext,0, notificationIntent, 0) } val notification: NotificationCompat.Builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID_INFO) .setPriority(NotificationManagerCompat.IMPORTANCE_DEFAULT) // No direct user action expected .setContentTitle(applicationContext.resources.getString(R.string.Notify_Error_Short)) .setContentText(applicationContext.resources.getString(R.string.Notify_Error_Long)) .setSmallIcon(R.drawable.uncivnotification) // without at least vibrate, some Android versions don't show a heads-up notification .setDefaults(DEFAULT_VIBRATE) .setLights(Color.YELLOW, 300, 100) .setContentIntent(pendingLaunchGameIntent) .setCategory(NotificationCompat.CATEGORY_ERROR) .setOngoing(false) .addAction(0, applicationContext.resources.getString(R.string.Notify_Error_CopyAction), pendingCopyClipboardIntent) with(NotificationManagerCompat.from(applicationContext)) { notify(NOTIFICATION_ID_INFO, notification.build()) } } }