Merge pull request #795 from simplex-chat/master

Merge master to stable
This commit is contained in:
Evgeny Poberezkin
2022-07-09 13:53:13 +01:00
committed by GitHub
152 changed files with 8341 additions and 3927 deletions

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ stack.yaml.lock
# Temporary test files
tests/tmp
logs/

View File

@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 36
versionName "2.2"
versionCode 41
versionName "3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {

View File

@@ -24,8 +24,8 @@ var TransformOperation;
let activeCall;
const processCommand = (function () {
const defaultIceServers = [
{ urls: ["stun:stun.simplex.chat:5349"] },
{ urls: ["turn:turn.simplex.chat:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
{ urls: ["stun:stun.simplex.im:5349"] },
{ urls: ["turn:turn.simplex.im:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
];
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
return {

View File

@@ -24,9 +24,11 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
// from simplex-chat
typedef void* chat_ctrl;
extern chat_ctrl chat_init(const char * path);
extern chat_ctrl chat_init(const char *path);
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl);
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
@@ -48,3 +50,16 @@ JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}

View File

@@ -305,7 +305,8 @@ fun MainPage(
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else {
showAdvertiseLAAlert = true
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) })
val stopped = chatModel.chatRunning.value == false
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped)
else ChatView(chatModel)
}
}

View File

@@ -24,8 +24,10 @@ external fun pipeStdOutToSocket(socketName: String) : Int
// SimpleX API
typealias ChatCtrl = Long
external fun chatInit(path: String): ChatCtrl
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
external fun chatRecvMsg(ctrl: ChatCtrl) : String
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
val chatController: ChatController by lazy {
@@ -55,7 +57,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
SimplexService.start(applicationContext)
chatController.showBackgroundServiceNoticeIfNeeded()
}
}
@@ -66,13 +67,14 @@ class SimplexApp: Application(), LifecycleEventObserver {
withApi {
when (event) {
Lifecycle.Event.ON_STOP ->
if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext)
if (appPreferences.runServiceInBackground.get() && chatModel.chatRunning.value != false) SimplexService.start(applicationContext)
Lifecycle.Event.ON_START ->
SimplexService.start(applicationContext)
SimplexService.stop(applicationContext)
Lifecycle.Event.ON_RESUME ->
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
chatController.showBackgroundServiceNoticeIfNeeded()
}
else -> {}
}
}
}

View File

@@ -7,6 +7,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
@@ -20,6 +21,7 @@ class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private var isStartingService = false
private var isStoppingService = false
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
private val chatController by lazy { (application as SimplexApp).chatController }
@@ -47,7 +49,6 @@ class SimplexService: Service() {
val text = getString(R.string.simplex_service_notification_text)
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
}
@@ -71,7 +72,6 @@ class SimplexService: Service() {
} else {
Log.w(TAG, "Starting foreground service")
chatController.startChat(user)
chatController.startReceiver()
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
@@ -88,6 +88,8 @@ class SimplexService: Service() {
private fun stopService() {
Log.d(TAG, "Stopping foreground service")
if (isStoppingService) return
isStoppingService = true
try {
wakeLock?.let {
while (it.isHeld) it.release() // release all, in case acquired more than once
@@ -98,7 +100,7 @@ class SimplexService: Service() {
} catch (e: Exception) {
Log.d(TAG, "Service stopped without being started: ${e.message}")
}
isStoppingService = false
isServiceStarted = false
saveServiceState(this, ServiceState.STOPPED)
}
@@ -121,7 +123,7 @@ class SimplexService: Service() {
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_icon)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(text)
@@ -213,6 +215,7 @@ class SimplexService: Service() {
suspend fun stop(context: Context) = serviceAction(context, Action.STOP)
private suspend fun serviceAction(context: Context, action: Action) {
if (!AppPreferences(context).runServiceInBackground.get()) { return }
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
withContext(Dispatchers.IO) {
Intent(context, SimplexService::class.java).also {

View File

@@ -23,6 +23,8 @@ class ChatModel(val controller: ChatController) {
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
val currentUser = mutableStateOf<User?>(null)
val userCreated = mutableStateOf<Boolean?>(null)
val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false)
val chats = mutableStateListOf<Chat>()
val chatId = mutableStateOf<String?>(null)
val chatItems = mutableStateListOf<ChatItem>()
@@ -45,8 +47,8 @@ class ChatModel(val controller: ChatController) {
// current WebRTC call
val callManager = CallManager(this)
val callInvitations = mutableStateMapOf<String, CallInvitation>()
val activeCallInvitation = mutableStateOf<CallInvitation?>(null)
val callInvitations = mutableStateMapOf<String, RcvCallInvitation>()
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
val activeCall = mutableStateOf<Call?>(null)
val callCommand = mutableStateOf<WCallCommand?>(null)
val showCallView = mutableStateOf(false)

View File

@@ -102,7 +102,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
}
fun notifyCallInvitation(invitation: CallInvitation) {
fun notifyCallInvitation(invitation: RcvCallInvitation) {
if (isAppOnForeground(context)) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
@@ -124,7 +124,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
.setSound(soundUri)
}
val text = generalGetString(
if (invitation.peerMedia == CallMediaType.Video) {
if (invitation.callType.media == CallMediaType.Video) {
if (invitation.sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
} else {
if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call

View File

@@ -81,6 +81,9 @@ class AppPreferences(val context: Context) {
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
private fun mkIntPreference(prefName: String, default: Int) =
Preference(
@@ -100,6 +103,15 @@ class AppPreferences(val context: Context) {
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
)
private fun mkDatePreference(prefName: String, default: Instant?): Preference<Instant?> =
Preference(
get = {
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
},
set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).apply()
)
companion object {
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
@@ -113,12 +125,17 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
}
}
private const val MESSAGE_TIMEOUT: Int = 15_000_000
open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
val chatModel = ChatModel(this)
private var receiverStarted = false
init {
chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get()
@@ -128,12 +145,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun startChat(user: User) {
Log.d(TAG, "user: $user")
try {
val chatStarted = apiStartChat()
val justStarted = apiStartChat()
apiSetFilesFolder(getAppFilesDirectory(appContext))
chatModel.userAddress.value = apiGetUserAddress()
chatModel.userSMPServers.value = getUserSMPServers()
val chats = apiGetChats()
if (chatStarted) {
if (justStarted) {
chatModel.chats.clear()
chatModel.chats.addAll(chats)
} else {
@@ -142,6 +159,9 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
chatModel.currentUser.value = user
chatModel.userCreated.value = true
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
startReceiver()
Log.d(TAG, "chat started")
} catch (e: Error) {
Log.e(TAG, "failed starting chat $e")
@@ -149,8 +169,9 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
fun startReceiver() {
private fun startReceiver() {
Log.d(TAG, "ChatController startReceiver")
if (receiverStarted) return
thread(name="receiver") {
GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } }
}
@@ -176,18 +197,23 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
suspend fun recvMsg(): CR {
private suspend fun recvMsg(): CR? {
return withContext(Dispatchers.IO) {
val json = chatRecvMsg(ctrl)
val r = APIResponse.decodeStr(json).resp
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
r
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
if (json == "") {
null
} else {
val r = APIResponse.decodeStr(json).resp
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
r
}
}
}
suspend fun recvMspLoop() {
processReceivedMsg(recvMsg())
private suspend fun recvMspLoop() {
val msg = recvMsg()
if (msg != null) processReceivedMsg(msg)
recvMspLoop()
}
@@ -215,13 +241,39 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
suspend fun apiSetFilesFolder(filesFolder: String) {
suspend fun apiStopChat(): Boolean {
val r = sendCmd(CC.ApiStopChat())
when (r) {
is CR.ChatStopped -> return true
else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}")
}
}
private suspend fun apiSetFilesFolder(filesFolder: String) {
val r = sendCmd(CC.SetFilesFolder(filesFolder))
if (r is CR.CmdOk) return
throw Error("failed to set files folder: ${r.responseType} ${r.details}")
}
suspend fun apiGetChats(): List<Chat> {
suspend fun apiExportArchive(config: ArchiveConfig) {
val r = sendCmd(CC.ApiExportArchive(config))
if (r is CR.CmdOk) return
throw Error("failed to export archive: ${r.responseType} ${r.details}")
}
suspend fun apiImportArchive(config: ArchiveConfig) {
val r = sendCmd(CC.ApiImportArchive(config))
if (r is CR.CmdOk) return
throw Error("failed to import archive: ${r.responseType} ${r.details}")
}
suspend fun apiDeleteStorage() {
val r = sendCmd(CC.ApiDeleteStorage())
if (r is CR.CmdOk) return
throw Error("failed to delete storage: ${r.responseType} ${r.details}")
}
private suspend fun apiGetChats(): List<Chat> {
val r = sendCmd(CC.ApiGetChats())
if (r is CR.ApiChats ) return r.chats
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
@@ -256,7 +308,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
suspend fun getUserSMPServers(): List<String>? {
private suspend fun getUserSMPServers(): List<String>? {
val r = sendCmd(CC.GetUserSMPServers())
if (r is CR.UserSMPServers) return r.smpServers
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
@@ -383,7 +435,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return false
}
suspend fun apiGetUserAddress(): String? {
private suspend fun apiGetUserAddress(): String? {
val r = sendCmd(CC.ShowMyAddress())
if (r is CR.UserContactLink) return r.connReqContact
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
@@ -566,10 +618,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
removeFile(appContext, fileName)
}
}
is CR.CallInvitation -> {
val invitation = CallInvitation(r.contact, r.callType.media, r.sharedKey, r.callTs)
chatModel.callManager.reportNewIncomingCall(invitation)
}
is CR.CallInvitation ->
chatModel.callManager.reportNewIncomingCall(r.callInvitation)
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
@@ -837,7 +887,11 @@ sealed class CC {
class ShowActiveUser: CC()
class CreateActiveUser(val profile: Profile): CC()
class StartChat: CC()
class ApiStopChat: CC()
class SetFilesFolder(val filesFolder: String): CC()
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC()
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
@@ -871,7 +925,11 @@ sealed class CC {
is ShowActiveUser -> "/u"
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
is StartChat -> "/_start"
is ApiStopChat -> "/_stop"
is SetFilesFolder -> "/_files_folder $filesFolder"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete"
is ApiGetChats -> "/_get chats pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
@@ -906,7 +964,11 @@ sealed class CC {
is ShowActiveUser -> "showActiveUser"
is CreateActiveUser -> "createActiveUser"
is StartChat -> "startChat"
is ApiStopChat -> "apiStopChat"
is SetFilesFolder -> "setFilesFolder"
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
@@ -948,6 +1010,9 @@ sealed class CC {
@Serializable
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
@Serializable
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
@@ -981,6 +1046,7 @@ sealed class CR {
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
@@ -1022,7 +1088,7 @@ sealed class CR {
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR()
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@@ -1039,6 +1105,7 @@ sealed class CR {
is ActiveUser -> "activeUser"
is ChatStarted -> "chatStarted"
is ChatRunning -> "chatRunning"
is ChatStopped -> "chatStopped"
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is UserSMPServers -> "userSMPServers"
@@ -1098,6 +1165,7 @@ sealed class CR {
is ActiveUser -> json.encodeToString(user)
is ChatStarted -> noDetails()
is ChatRunning -> noDetails()
is ChatStopped -> noDetails()
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is UserSMPServers -> json.encodeToString(smpServers)
@@ -1139,7 +1207,7 @@ sealed class CR {
is SndFileRcvCancelled -> json.encodeToString(chatItem)
is SndFileStart -> json.encodeToString(chatItem)
is SndGroupFileCancelled -> json.encodeToString(chatItem)
is CallInvitation -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}"
is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}"
is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}"

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -17,8 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.SendMsgView
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
@@ -82,6 +82,10 @@ fun TerminalLayout(
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>) {
val listState = rememberLazyListState()
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
mutableStateOf(CIListState(false, terminalItems.count(), keyboardState))
}
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(terminalItems) { item ->
@@ -101,8 +105,9 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
)
}
val len = terminalItems.count()
if (len > 1) {
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
}

View File

@@ -114,7 +114,6 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
SimplexService.start(chatModel.controller.appContext)
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
}

View File

@@ -9,11 +9,10 @@ import kotlinx.datetime.Clock
import kotlin.time.Duration.Companion.minutes
class CallManager(val chatModel: ChatModel) {
fun reportNewIncomingCall(invitation: CallInvitation) {
fun reportNewIncomingCall(invitation: RcvCallInvitation) {
Log.d(TAG, "CallManager.reportNewIncomingCall")
with (chatModel) {
callInvitations[invitation.contact.id] = invitation
if (!chatModel.controller.appPrefs.experimentalCalls.get()) return
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
activeCallInvitation.value = invitation
controller.ntfManager.notifyCallInvitation(invitation)
@@ -24,7 +23,7 @@ class CallManager(val chatModel: ChatModel) {
}
}
fun acceptIncomingCall(invitation: CallInvitation) {
fun acceptIncomingCall(invitation: RcvCallInvitation) {
ModalManager.shared.closeModals()
val call = chatModel.activeCall.value
if (call == null) {
@@ -42,17 +41,17 @@ class CallManager(val chatModel: ChatModel) {
}
}
private fun justAcceptIncomingCall(invitation: CallInvitation) {
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
with (chatModel) {
activeCall.value = Call(
contact = invitation.contact,
callState = CallState.InvitationAccepted,
localMedia = invitation.peerMedia,
localMedia = invitation.callType.media,
sharedKey = invitation.sharedKey
)
showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
callCommand.value = WCallCommand.Start (media = invitation.peerMedia, aesKey = invitation.sharedKey, relay = useRelay)
callCommand.value = WCallCommand.Start (media = invitation.callType.media, aesKey = invitation.sharedKey, relay = useRelay)
callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
activeCallInvitation.value = null
@@ -77,7 +76,7 @@ class CallManager(val chatModel: ChatModel) {
}
}
fun endCall(invitation: CallInvitation) {
fun endCall(invitation: RcvCallInvitation) {
with (chatModel) {
callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
@@ -92,7 +91,7 @@ class CallManager(val chatModel: ChatModel) {
}
}
fun reportCallRemoteEnded(invitation: CallInvitation) {
fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
chatModel.activeCallInvitation.value = null
chatModel.controller.ntfManager.cancelCallNotification()

View File

@@ -1,6 +1,8 @@
package chat.simplex.app.views.call
import android.Manifest
import android.content.Context
import android.media.AudioManager
import android.util.Log
import android.view.ViewGroup
import android.webkit.*
@@ -35,6 +37,7 @@ import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@@ -44,6 +47,8 @@ fun ActiveCallView(chatModel: ChatModel) {
val call = chatModel.activeCall.value
if (call != null) withApi { chatModel.callManager.endCall(call) }
})
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
@@ -79,6 +84,10 @@ fun ActiveCallView(chatModel: ChatModel) {
}
is WCallResponse.Connected -> {
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
scope.launch {
delay(2000L)
setCallSound(cxt, call)
}
}
is WCallResponse.Ended -> {
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
@@ -117,21 +126,43 @@ fun ActiveCallView(chatModel: ChatModel) {
@Composable
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
var cxt = LocalContext.current
ActiveCallOverlayLayout(
call = call,
dismiss = { withApi { chatModel.callManager.endCall(call) } },
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
toggleSound = {
var call = chatModel.activeCall.value
if (call != null) {
call = call.copy(soundSpeaker = !call.soundSpeaker)
chatModel.activeCall.value = call
setCallSound(cxt, call)
}
},
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
)
}
private fun setCallSound(cxt: Context, call: Call) {
Log.d(TAG, "setCallSound: set audio mode")
val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (call.soundSpeaker) {
am.mode = AudioManager.MODE_NORMAL
am.isSpeakerphoneOn = true
} else {
am.mode = AudioManager.MODE_IN_CALL
am.isSpeakerphoneOn = false
}
}
@Composable
private fun ActiveCallOverlayLayout(
call: Call,
dismiss: () -> Unit,
toggleAudio: () -> Unit,
toggleVideo: () -> Unit,
toggleSound: () -> Unit,
flipCamera: () -> Unit
) {
Column(Modifier.padding(16.dp)) {
@@ -174,6 +205,11 @@ private fun ActiveCallOverlayLayout(
Box(Modifier.padding(start = 32.dp)) {
ToggleAudioButton(call, toggleAudio)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.padding(end = 32.dp)) {
ToggleSoundButton(call, toggleSound)
}
}
}
}
}
@@ -194,12 +230,21 @@ private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: In
@Composable
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
if (call.audioEnabled) {
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_video_off, toggleAudio)
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_audio_off, toggleAudio)
} else {
ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
}
}
@Composable
private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) {
if (call.soundSpeaker) {
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
} else {
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
}
}
@Composable
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
@@ -393,6 +438,7 @@ fun PreviewActiveCallOverlayVideo() {
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}
@@ -413,6 +459,7 @@ fun PreviewActiveCallOverlayAudio() {
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}

View File

@@ -112,7 +112,7 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
}
@Composable
fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
@@ -141,7 +141,7 @@ fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel
@Composable
fun IncomingCallLockScreenAlertLayout(
invitation: CallInvitation,
invitation: RcvCallInvitation,
callOnLockScreen: CallOnLockScreen?,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
@@ -210,9 +210,9 @@ fun PreviewIncomingCallLockScreenAlert() {
.background(MaterialTheme.colors.background)
.fillMaxSize()) {
IncomingCallLockScreenAlertLayout(
invitation = CallInvitation(
invitation = RcvCallInvitation(
contact = Contact.sampleData,
peerMedia = CallMediaType.Audio,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callTs = Clock.System.now()
),

View File

@@ -24,7 +24,7 @@ import chat.simplex.app.views.usersettings.ProfilePreview
import kotlinx.datetime.Clock
@Composable
fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) {
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
@@ -40,7 +40,7 @@ fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) {
@Composable
fun IncomingCallAlertLayout(
invitation: CallInvitation,
invitation: RcvCallInvitation,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
acceptCall: () -> Unit
@@ -60,10 +60,10 @@ fun IncomingCallAlertLayout(
}
@Composable
fun IncomingCallInfo(invitation: CallInvitation) {
fun IncomingCallInfo(invitation: RcvCallInvitation) {
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
Row {
if (invitation.peerMedia == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
Spacer(Modifier.width(4.dp))
Text(invitation.callTypeText)
@@ -94,9 +94,9 @@ private fun CallButton(text: String, icon: ImageVector, color: Color, action: ()
fun PreviewIncomingCallAlertLayout() {
SimpleXTheme {
IncomingCallAlertLayout(
invitation = CallInvitation(
invitation = RcvCallInvitation(
contact = Contact.sampleData,
peerMedia = CallMediaType.Audio,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
sharedKey = null,
callTs = Clock.System.now()
),

View File

@@ -18,6 +18,7 @@ data class Call(
val sharedKey: String? = null,
val audioEnabled: Boolean = true,
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null
) {
@@ -90,12 +91,12 @@ sealed class WCallResponse {
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(peerMedia) {
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(callType.media) {
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
})
val callTitle: String get() = generalGetString(when(peerMedia) {
val callTitle: String get() = generalGetString(when(callType.media) {
CallMediaType.Video -> R.string.incoming_video_call
CallMediaType.Audio -> R.string.incoming_audio_call
})

View File

@@ -45,7 +45,6 @@ fun ChatView(chatModel: ChatModel) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val enableCalls = chatModel.controller.appPrefs.experimentalCalls.get()
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) }
val attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
@@ -86,7 +85,6 @@ fun ChatView(chatModel: ChatModel) {
attachmentBottomSheetState,
chatModel.chatItems,
useLinkPreviews = useLinkPreviews,
enableCalls = enableCalls,
back = { chatModel.chatId.value = null },
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
openDirectChat = { contactId ->
@@ -141,7 +139,6 @@ fun ChatLayout(
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
useLinkPreviews: Boolean,
enableCalls: Boolean = false,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
@@ -169,7 +166,7 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, enableCalls, back, info, startCall) },
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
@@ -183,7 +180,7 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
@Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) {
IconButton(onClick, modifier = modifier) {
Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary)
@@ -200,7 +197,7 @@ fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, back: () -> Unit, info: ()
) {
val cInfo = chat.chatInfo
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
if (cInfo is ChatInfo.Direct && enableCalls) {
if (cInfo is ChatInfo.Direct) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) {
toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) {

View File

@@ -1,6 +1,7 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -28,6 +29,7 @@ import kotlinx.datetime.Clock
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
val showMenu = remember { mutableStateOf(false) }
var showMarkRead by remember { mutableStateOf(false) }
val stopped = chatModel.chatRunning.value == false
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
showMenu.value = false
delay(500L)
@@ -36,31 +38,35 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
when (chat.chatInfo) {
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat) },
chatLinkPreview = { ChatPreviewView(chat, stopped) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu
showMenu,
stopped
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat) },
chatLinkPreview = { ChatPreviewView(chat, stopped) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu
showMenu,
stopped
)
is ChatInfo.ContactRequest ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu
showMenu,
stopped
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu
showMenu,
stopped
)
}
}
@@ -286,17 +292,12 @@ fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>
showMenu: MutableState<Boolean>,
stopped: Boolean
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = click,
onLongClick = { showMenu.value = true }
)
.height(88.dp)
) {
var modifier = Modifier.fillMaxWidth().height(88.dp)
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -345,12 +346,14 @@ fun PreviewChatListNavLinkDirect() {
)
),
chatStats = Chat.ChatStats()
)
),
stopped = false
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}
@@ -378,12 +381,14 @@ fun PreviewChatListNavLinkGroup() {
)
),
chatStats = Chat.ChatStats()
)
),
stopped = false
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}
@@ -403,7 +408,8 @@ fun PreviewChatListNavLinkContactRequest() {
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }
showMenu = remember { mutableStateOf(false) },
stopped = false
)
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.runtime.*
@@ -20,6 +21,8 @@ import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.ToolbarDark
import chat.simplex.app.ui.theme.ToolbarLight
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.onboarding.MakeConnection
import chat.simplex.app.views.usersettings.SettingsView
@@ -64,7 +67,7 @@ fun scaffoldController(): ScaffoldController {
}
@Composable
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val scaffoldCtrl = scaffoldController()
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
@@ -82,7 +85,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl)
ChatListToolbar(scaffoldCtrl, stopped)
Divider()
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
@@ -103,7 +106,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
}
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -127,13 +130,24 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(5.dp)
)
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
if (!stopped) {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
} else {
IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) {
Icon(
Icons.Filled.Report,
generalGetString(R.string.chat_is_stopped_indication),
tint = Color.Red,
modifier = Modifier.padding(10.dp)
)
}
}
}
}

View File

@@ -26,7 +26,7 @@ import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
@Composable
fun ChatPreviewView(chat: Chat) {
fun ChatPreviewView(chat: Chat, stopped: Boolean) {
Row {
val cInfo = chat.chatInfo
ChatInfoImage(cInfo, size = 72.dp)
@@ -80,7 +80,7 @@ fun ChatPreviewView(chat: Chat) {
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
@@ -131,6 +131,6 @@ fun ChatStatusImage(chat: Chat) {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData)
ChatPreviewView(Chat.sampleData, stopped = false)
}
}

View File

@@ -0,0 +1,145 @@
package chat.simplex.app.views.database
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import chat.simplex.app.views.usersettings.SettingsSectionView
import kotlinx.datetime.*
import java.io.BufferedOutputStream
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) {
val context = LocalContext.current
val archivePath = "${getFilesDirectory(context)}/$archiveName"
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, archivePath)
ChatArchiveLayout(
title,
archiveTime,
saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) },
deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) }
)
}
@Composable
fun ChatArchiveLayout(
title: String,
archiveTime: Instant,
saveArchive: () -> Unit,
deleteArchiveAlert: () -> Unit
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Text(
title,
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.chat_database_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.save_archive),
saveArchive,
textColor = MaterialTheme.colors.primary
)
divider()
SettingsActionItem(
Icons.Outlined.Delete,
stringResource(R.string.delete_archive),
deleteArchiveAlert,
textColor = Color.Red
)
}
val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
SettingsSectionFooter(
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
)
}
}
@Composable
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
try {
destination?.let {
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(chatArchivePath)
outputStream.write(file.readBytes())
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
}
} catch (e: Error) {
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
}
}
)
private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_chat_archive_question),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = {
val fileDeleted = File(archivePath).delete()
if (fileDeleted) {
m.controller.appPrefs.chatArchiveName.set(null)
m.controller.appPrefs.chatArchiveTime.set(null)
ModalManager.shared.closeModal()
} else {
Log.e(TAG, "deleteArchiveAlert delete() error")
}
}
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatArchiveLayout() {
SimpleXTheme {
ChatArchiveLayout(
title = "New database archive",
archiveTime = Clock.System.now(),
saveArchive = {},
deleteArchiveAlert = {}
)
}
}

View File

@@ -0,0 +1,471 @@
package chat.simplex.app.views.database
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.os.FileUtils
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import kotlinx.datetime.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun DatabaseView(
m: ChatModel,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val context = LocalContext.current
val progressIndicator = remember { mutableStateOf(false) }
val runChat = remember { mutableStateOf(false) }
val prefs = m.controller.appPrefs
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
val chatArchiveFile = remember { mutableStateOf<String?>(null) }
val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile)
val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
importArchiveAlert(m, context, uri, progressIndicator)
}
}
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
Box(
Modifier.fillMaxSize(),
) {
DatabaseLayout(
progressIndicator.value,
runChat.value,
m.chatDbChanged.value,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
chatLastStart,
startChat = { startChat(m, runChat) },
stopChatAlert = { stopChatAlert(m, runChat) },
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
showSettingsModal
)
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
}
@Composable
fun DatabaseLayout(
progressIndicator: Boolean,
runChat: Boolean,
chatDbChanged: Boolean,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
startChat: () -> Unit,
stopChatAlert: () -> Unit,
exportArchive: () -> Unit,
deleteChatAlert: () -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator || chatDbChanged
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
@Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp))
Text(
stringResource(R.string.your_chat_database),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
}
Spacer(Modifier.height(30.dp))
SettingsSectionView(stringResource(R.string.chat_database_section)) {
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
exportArchive,
textColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
divider()
SettingsActionItem(
Icons.Outlined.FileDownload,
stringResource(R.string.import_database),
{ importArchiveLauncher.launch("application/zip") },
textColor = Color.Red,
disabled = operationsDisabled
)
divider()
val chatArchiveNameVal = chatArchiveName.value
val chatArchiveTimeVal = chatArchiveTime.value
val chatLastStartVal = chatLastStart.value
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
SettingsActionItem(
Icons.Outlined.Inventory2,
title,
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
disabled = operationsDisabled
)
divider()
}
SettingsActionItem(
Icons.Outlined.DeleteForever,
stringResource(R.string.delete_database),
deleteChatAlert,
textColor = Color.Red,
disabled = operationsDisabled
)
}
SettingsSectionFooter(
if (chatDbChanged) {
stringResource(R.string.restart_the_app_to_use_new_chat_database)
} else {
if (stopped) {
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(R.string.stop_chat_to_enable_database_actions)
}
}
)
}
}
@Composable
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
chatDbChanged: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
SettingsItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
Icon(
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
chatRunningText,
tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
chatRunningText,
Modifier.padding(end = 24.dp),
color = if (chatDbChanged) HighOrLowlight else Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = runChat,
onCheckedChange = { runChatSwitch ->
if (runChatSwitch) {
startChat()
} else {
stopChatAlert()
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
enabled = !chatDbChanged
)
}
}
}
@Composable
fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
}
@Composable
fun SettingsSectionFooter(text: String) {
Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>) {
withApi {
try {
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
m.controller.appPrefs.chatLastStart.set(Clock.System.now())
} catch (e: Error) {
runChat.value = false
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
}
}
}
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.stop_chat_question),
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
confirmText = generalGetString(R.string.stop_chat_confirmation),
onConfirm = { stopChat(m, runChat) },
onDismiss = { runChat.value = true }
)
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>) {
withApi {
try {
m.controller.apiStopChat()
runChat.value = false
m.chatRunning.value = false
} catch (e: Error) {
runChat.value = true
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
}
}
}
private fun exportArchive(
context: Context,
m: ChatModel,
progressIndicator: MutableState<Boolean>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatArchiveFile: MutableState<String?>,
saveArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>
) {
progressIndicator.value = true
withApi {
try {
val archiveFile = exportChatArchive(m, context, chatArchiveName, chatArchiveTime, chatArchiveFile)
chatArchiveFile.value = archiveFile
saveArchiveLauncher.launch(archiveFile.substringAfterLast("/"))
progressIndicator.value = false
} catch (e: Error) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_exporting_chat_database), e.toString())
progressIndicator.value = false
}
}
}
private suspend fun exportChatArchive(
m: ChatModel,
context: Context,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatArchiveFile: MutableState<String?>
): String {
val archiveTime = Clock.System.now()
val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
val archiveName = "simplex-chat.$ts.zip"
val archivePath = "${getFilesDirectory(context)}/$archiveName"
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
m.controller.apiExportArchive(config)
deleteOldArchive(m, context)
m.controller.appPrefs.chatArchiveName.set(archiveName)
chatArchiveName.value = archiveName
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
chatArchiveTime.value = archiveTime
chatArchiveFile.value = archivePath
return archivePath
}
private fun deleteOldArchive(m: ChatModel, context: Context) {
val chatArchiveName = m.controller.appPrefs.chatArchiveName.get()
if (chatArchiveName != null) {
val file = File("${getFilesDirectory(context)}/$chatArchiveName")
val fileDeleted = file.delete()
if (fileDeleted) {
m.controller.appPrefs.chatArchiveName.set(null)
m.controller.appPrefs.chatArchiveTime.set(null)
} else {
Log.e(TAG, "deleteOldArchive file.delete() error")
}
}
}
@Composable
private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableState<String?>): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
try {
destination?.let {
val filePath = chatArchiveFile.value
if (filePath != null) {
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
val file = File(filePath)
outputStream.write(file.readBytes())
outputStream.close()
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
}
}
} catch (e: Error) {
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
} finally {
chatArchiveFile.value = null
}
}
)
private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.import_database_question),
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(R.string.import_database_confirmation),
onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) }
)
}
private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState<Boolean>) {
progressIndicator.value = true
val archivePath = saveArchiveFromUri(context, importedArchiveUri)
if (archivePath != null) {
withApi {
try {
m.controller.apiDeleteStorage()
try {
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
m.controller.apiImportArchive(config)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
}
} catch (e: Error) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_importing_database), e.toString())
}
}
} catch (e: Error) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
}
} finally {
File(archivePath).delete()
}
}
}
}
private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): String? {
return try {
val inputStream = context.contentResolver.openInputStream(importedArchiveUri)
val archiveName = getFileName(context, importedArchiveUri)
if (inputStream != null && archiveName != null) {
val archivePath = "${context.cacheDir}/$archiveName"
val destFile = File(archivePath)
FileUtils.copy(inputStream, FileOutputStream(destFile))
archivePath
} else {
Log.e(TAG, "saveArchiveFromUri null inputStream")
null
}
} catch (e: Exception) {
Log.e(TAG, "saveArchiveFromUri error: ${e.message}")
null
}
}
private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState<Boolean>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.delete_chat_profile_question),
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
confirmText = generalGetString(R.string.delete_verb),
onConfirm = { deleteChat(m, progressIndicator) }
)
}
private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
progressIndicator.value = true
withApi {
try {
m.controller.apiDeleteStorage()
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
}
} catch (e: Error) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString())
}
}
}
}
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
m.chatDbChanged.value = true
progressIndicator.value = false
alert.invoke()
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewDatabaseLayout() {
SimpleXTheme {
DatabaseLayout(
progressIndicator = false,
runChat = true,
chatDbChanged = false,
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
startChat = {},
stopChatAlert = {},
exportArchive = {},
deleteChatAlert = {},
showSettingsModal = { {} }
)
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -23,12 +24,14 @@ import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.database.DatabaseView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.SimpleXInfo
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
fun setRunServiceInBackground(on: Boolean) {
chatModel.controller.appPrefs.runServiceInBackground.set(on)
@@ -42,10 +45,10 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
if (user != null) {
SettingsLayout(
profile = user.profile,
stopped,
runServiceInBackground = chatModel.runServiceInBackground,
setRunServiceInBackground = ::setRunServiceInBackground,
setPerformLA = setPerformLA,
enableCalls = remember { mutableStateOf(chatModel.controller.appPrefs.experimentalCalls.get()) },
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close ->
ModalView(close = close, modifier = Modifier,
@@ -66,10 +69,10 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: Profile,
stopped: Boolean,
runServiceInBackground: MutableState<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
setPerformLA: (Boolean) -> Unit,
enableCalls: MutableState<Boolean>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
@@ -94,47 +97,47 @@ fun SettingsLayout(
Spacer(Modifier.height(30.dp))
SettingsSectionView(stringResource(R.string.settings_section_title_you)) {
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
ProfilePreview(profile)
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
divider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) })
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
divider()
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
spacer()
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
if (enableCalls.value) {
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) })
divider()
}
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) })
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
divider()
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground)
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
divider()
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped)
divider()
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped)
}
spacer()
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) })
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped)
divider()
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
divider()
SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() })
divider()
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary)
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
divider()
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
}
spacer()
SettingsSectionView(stringResource(R.string.settings_section_title_develop)) {
ChatConsoleItem(showTerminal)
ChatConsoleItem(showTerminal, stopped)
divider()
InstallTerminalAppItem(uriHandler)
divider()
SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
divider()
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) })
// divider()
AppVersionItem()
}
}
@@ -143,19 +146,49 @@ fun SettingsLayout(
@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) {
Column {
Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp)
Text(
title, color = HighOrLowlight, style = MaterialTheme.typography.body2,
modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp
)
Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) {
Column(Modifier.padding(horizontal = 6.dp)) { content() }
}
}
}
@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) {
SettingsItemView(openDatabaseView) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row {
Icon(
Icons.Outlined.Archive,
contentDescription = stringResource(R.string.database_export_and_import),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.database_export_and_import))
}
if (stopped) {
Icon(
Icons.Filled.Report,
contentDescription = stringResource(R.string.chat_is_stopped),
tint = Color.Red,
modifier = Modifier.padding(end = 6.dp)
)
}
}
}
}
@Composable private fun PrivateNotificationsItem(
runServiceInBackground: MutableState<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit
setRunServiceInBackground: (Boolean) -> Unit,
stopped: Boolean
) {
SettingsItemView() {
SettingsItemView(disabled = stopped) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Outlined.Bolt,
@@ -168,7 +201,8 @@ fun SettingsLayout(
Modifier
.padding(end = 24.dp)
.fillMaxWidth()
.weight(1f)
.weight(1f),
color = if (stopped) HighOrLowlight else Color.Unspecified
)
Switch(
checked = runServiceInBackground.value,
@@ -177,7 +211,8 @@ fun SettingsLayout(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 6.dp)
modifier = Modifier.padding(end = 6.dp),
enabled = !stopped
)
}
}
@@ -211,15 +246,18 @@ fun SettingsLayout(
}
}
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
SettingsItemView(showTerminal) {
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
SettingsItemView(showTerminal, disabled = stopped) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = stringResource(R.string.chat_console),
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.chat_console))
Text(
stringResource(R.string.chat_console),
color = if (stopped) HighOrLowlight else Color.Unspecified
)
}
}
@@ -241,7 +279,7 @@ fun SettingsLayout(
}
}
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary) {
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) {
ProfileImage(size = size, image = profileOf.image, color = color)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
@@ -249,19 +287,23 @@ fun SettingsLayout(
profileOf.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (stopped) HighOrLowlight else Color.Unspecified
)
Text(
profileOf.fullName,
color = if (stopped) HighOrLowlight else Color.Unspecified
)
Text(profileOf.fullName)
}
}
@Composable
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) {
val modifier = Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.height(height)
Row(
if (click == null) modifier else modifier.clickable(onClick = click),
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
verticalAlignment = Alignment.CenterVertically
) {
content()
@@ -269,11 +311,11 @@ fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (
}
@Composable
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified) {
SettingsItemView(click) {
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
SettingsItemView(click, disabled = disabled) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text, color = textColor)
Text(text, color = if (disabled) HighOrLowlight else textColor)
}
}
@@ -299,10 +341,10 @@ fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
stopped = false,
runServiceInBackground = remember { mutableStateOf(true) },
setRunServiceInBackground = {},
setPerformLA = {},
enableCalls = remember { mutableStateOf(true) },
showModal = { {} },
showSettingsModal = { {} },
showCustomModal = { {} },

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -244,6 +244,7 @@
<!-- settings - SettingsView.kt -->
<string name="your_settings">Настройки</string>
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
<string name="database_export_and_import">Экспорт и импорт архива чата</string>
<string name="about_simplex_chat">Информация о <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">Как использовать</string>
<string name="markdown_help">Форматирование сообщений</string>
@@ -404,6 +405,8 @@
<string name="icon_descr_video_on">Включить видео</string>
<string name="icon_descr_audio_off">Выключить звук</string>
<string name="icon_descr_audio_on">Включить звук</string>
<string name="icon_descr_speaker_off">Выключить спикер</string>
<string name="icon_descr_speaker_on">Включить спикер</string>
<string name="icon_descr_flip_camera">Перевернуть камеру</string>
<!-- Call items -->
@@ -437,4 +440,47 @@
<string name="settings_section_title_device">УСТРОЙСТВО</string>
<string name="settings_section_title_chats">ЧАТЫ</string>
<string name="settings_experimental_features">Экспериментальные функции</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Данные чата</string>
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
<string name="chat_is_running">Чат запущен</string>
<string name="chat_is_stopped">Чат остановлен</string>
<string name="chat_database_section">АРХИВ ЧАТА</string>
<string name="export_database">Экспорт архива чата</string>
<string name="import_database">Импорт архива чата</string>
<string name="new_database_archive">Новый архив чата</string>
<string name="old_database_archive">Старый архив чата</string>
<string name="delete_database">Удалить данные чата</string>
<string name="error_starting_chat">Ошибка при запуске чата</string>
<string name="stop_chat_question">Остановить чат?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен.</string>
<string name="stop_chat_confirmation">Остановить</string>
<string name="error_stopping_chat">Ошибка при остановке чата</string>
<string name="error_exporting_chat_database">Ошибка при экспорте архива чата</string>
<string name="import_database_question">Импортировать архив чата?</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.\nЭто действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.</string>
<string name="import_database_confirmation">Импортировать</string>
<string name="error_deleting_database">Ошибка при удалении данных чата</string>
<string name="error_importing_database">Ошибка при импорте архива чата</string>
<string name="chat_database_imported">Архив чата импортирован</string>
<string name="restart_the_app_to_use_imported_chat_database">Перезапустите приложение, чтобы использовать импортированные данные чата.</string>
<string name="delete_chat_profile_question">Удалить профиль?</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны.</string>
<string name="chat_database_deleted">Данные чата удалены</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Перезапустите приложение, чтобы создать новый профиль.</string>
<string name="you_must_use_the_most_recent_version_of_database">Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов.</string>
<string name="stop_chat_to_enable_database_actions">Остановите чат, чтобы разблокировать операции с архивом чата.</string>
<string name="restart_the_app_to_use_new_chat_database">Перезапустите приложение, чтобы использовать новый архив чата.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Чат остановлен</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Вы можете запустить чат через Настройки приложения или перезапустив приложение.</string>
<!-- ChatArchiveView.kt -->
<string name="chat_archive_header">Архив чата</string>
<string name="save_archive">Сохранить архив</string>
<string name="delete_archive">Удалить архив</string>
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Удалить архив чата?</string>
</resources>

View File

@@ -250,6 +250,7 @@
<!-- settings - SettingsView.kt -->
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
<string name="database_export_and_import">Database export &amp; import</string>
<string name="about_simplex_chat">About <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">How to use it</string>
<string name="markdown_help">Markdown help</string>
@@ -406,6 +407,8 @@
<string name="icon_descr_video_on">Video on</string>
<string name="icon_descr_audio_off">Audio off</string>
<string name="icon_descr_audio_on">Audio on</string>
<string name="icon_descr_speaker_off">Speaker off</string>
<string name="icon_descr_speaker_on">Speaker on</string>
<string name="icon_descr_flip_camera">Flip camera</string>
<!-- Call items -->
@@ -439,4 +442,47 @@
<string name="settings_section_title_device">DEVICE</string>
<string name="settings_section_title_chats">CHATS</string>
<string name="settings_experimental_features">Experimental features</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Your chat database</string>
<string name="run_chat_section">RUN CHAT</string>
<string name="chat_is_running">Chat is running</string>
<string name="chat_is_stopped">Chat is stopped</string>
<string name="chat_database_section">CHAT DATABASE</string>
<string name="export_database">Export database</string>
<string name="import_database">Import database</string>
<string name="new_database_archive">New database archive</string>
<string name="old_database_archive">Old database archive</string>
<string name="delete_database">Delete database</string>
<string name="error_starting_chat">Error starting chat</string>
<string name="stop_chat_question">Stop chat?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped.</string>
<string name="stop_chat_confirmation">Stop</string>
<string name="error_stopping_chat">Error stopping chat</string>
<string name="error_exporting_chat_database">Error exporting chat database</string>
<string name="import_database_question">Import chat database?</string>
<string name="your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one">Your current chat database will be DELETED and REPLACED with the imported one.\nThis action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</string>
<string name="import_database_confirmation">Import</string>
<string name="error_deleting_database">Error deleting chat database</string>
<string name="error_importing_database">Error importing chat database</string>
<string name="chat_database_imported">Chat database imported</string>
<string name="restart_the_app_to_use_imported_chat_database">Restart the app to use imported chat database.</string>
<string name="delete_chat_profile_question">Delete chat profile?</string>
<string name="delete_chat_profile_action_cannot_be_undone_warning">This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.</string>
<string name="chat_database_deleted">Chat database deleted</string>
<string name="restart_the_app_to_create_a_new_chat_profile">Restart the app to create a new chat profile.</string>
<string name="you_must_use_the_most_recent_version_of_database">You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.</string>
<string name="stop_chat_to_enable_database_actions">Stop chat to enable database actions.</string>
<string name="restart_the_app_to_use_new_chat_database">Restart the app to use new chat database.</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Chat is stopped</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">You can start chat via app Settings / Database or by restarting the app.</string>
<!-- ChatArchiveView.kt -->
<string name="chat_archive_header">Chat archive</string>
<string name="save_archive">Save archive</string>
<string name="delete_archive">Delete archive</string>
<string name="archive_created_on_ts">Created on <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Delete chat archive?</string>
</resources>

View File

@@ -8,6 +8,7 @@
import Foundation
import UIKit
import SimpleXChat
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -20,18 +21,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
let m = ChatModel.shared
m.deviceToken = token
UserDefaults.standard.set(false, forKey: DEFAULT_USE_NOTIFICATIONS)
// let useNotifications = UserDefaults.standard.bool(forKey: "useNotifications")
// if useNotifications {
// Task {
// do {
// m.tokenStatus = try await apiRegisterToken(token: token)
// } catch {
// logger.error("apiRegisterToken error: \(responseError(error))")
// }
// }
// }
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
m.deviceToken = deviceToken
if m.savedToken != nil {
registerToken(token: deviceToken)
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
@@ -42,19 +36,19 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
logger.debug("AppDelegate: didReceiveRemoteNotification")
print("*** userInfo", userInfo)
let m = ChatModel.shared
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
UserDefaults.standard.bool(forKey: "useNotifications") {
m.notificationMode != .off {
if let verification = ntfData["verification"] as? String,
let nonce = ntfData["nonce"] as? String {
if let token = ChatModel.shared.deviceToken {
logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)")
Task {
let m = ChatModel.shared
do {
if case .active = m.tokenStatus {} else { m.tokenStatus = .confirmed }
try await apiVerifyToken(token: token, code: verification, nonce: nonce)
try await apiVerifyToken(token: token, nonce: nonce, code: verification)
m.tokenStatus = .active
try await apiIntervalNofication(token: token, interval: 20)
} catch {
if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr {
m.tokenStatus = .expired
@@ -67,15 +61,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
completionHandler(.noData)
}
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
// TODO check if app in background
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
// TODO remove
// NtfManager.shared.notifyCheckingMessages()
receiveMessages(completionHandler)
} else if let smpQueue = ntfData["checkMessage"] as? String {
// TODO check if app in background
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessage \(smpQueue)")
receiveMessages(completionHandler)
if appStateGroupDefault.get().inactive {
receiveMessages(completionHandler)
} else {
completionHandler(.noData)
}
} else {
completionHandler(.noData)
}
@@ -84,6 +75,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}
}
func applicationWillTerminate(_ application: UIApplication) {
logger.debug("AppDelegate: applicationWillTerminate")
terminateChat()
}
private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
let complete = BGManager.shared.completionHandler {
logger.debug("AppDelegate: completed BGManager.receiveMessages")

View File

@@ -22,39 +22,45 @@ struct ContentView: View {
ZStack {
if prefPerformLA && userAuthorized != true {
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
} else {
if let step = chatModel.onboardingStage {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
ZStack(alignment: .top) {
ChatListView(showChatInfo: $showChatInfo)
.onAppear {
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice) {
prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert())
}
prefShowLANotice = true
}
if chatModel.showCallView, let call = chatModel.activeCall {
ActiveCallView(call: call)
}
IncomingCallView()
}
} else {
OnboardingView(onboarding: step)
}
} else if !chatModel.v3DBMigration.startChat {
MigrateToAppGroupView()
} else if let step = chatModel.onboardingStage {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
} else {
OnboardingView(onboarding: step)
}
}
}
.onAppear { if doAuthenticate { runAuthenticate() } }
.onAppear {
if doAuthenticate { runAuthenticate() }
}
.onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } }
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
}
private func mainView() -> some View {
ZStack(alignment: .top) {
ChatListView(showChatInfo: $showChatInfo)
.onAppear {
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
// Local Authentication notice is to be shown on next start after onboarding is complete
if (!prefLANoticeShown && prefShowLANotice) {
prefLANoticeShown = true
alertManager.showAlert(laNoticeAlert())
}
prefShowLANotice = true
}
if chatModel.showCallView, let call = chatModel.activeCall {
ActiveCallView(call: call)
}
IncomingCallView()
}
}
private func runAuthenticate() {
if !prefPerformLA {
userAuthorized = true

View File

@@ -8,6 +8,7 @@
import Foundation
import BackgroundTasks
import SimpleXChat
private let receiveTaskId = "chat.simplex.app.receive"
@@ -16,11 +17,14 @@ private let waitForMessages: TimeInterval = 6
private let bgRefreshInterval: TimeInterval = 450
private let maxTimerCount = 9
class BGManager {
static let shared = BGManager()
var chatReceiver: ChatReceiver?
var bgTimer: Timer?
var completed = true
var timerCount = 0
func register() {
logger.debug("BGManager.register")
@@ -43,11 +47,16 @@ class BGManager {
private func handleRefresh(_ task: BGAppRefreshTask) {
logger.debug("BGManager.handleRefresh")
schedule()
let completeRefresh = completionHandler {
if appStateGroupDefault.get().inactive {
let completeRefresh = completionHandler {
task.setTaskCompleted(success: true)
}
task.expirationHandler = { completeRefresh("expirationHandler") }
receiveMessages(completeRefresh)
} else {
logger.debug("BGManager.completionHandler: already active, not started")
task.setTaskCompleted(success: true)
}
task.expirationHandler = { completeRefresh("expirationHandler") }
receiveMessages(completeRefresh)
}
func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) {
@@ -59,6 +68,8 @@ class BGManager {
self.chatReceiver = nil
self.bgTimer?.invalidate()
self.bgTimer = nil
self.timerCount = 0
suspendBgRefresh()
complete()
}
}
@@ -71,20 +82,28 @@ class BGManager {
}
self.completed = false
DispatchQueue.main.async {
initializeChat()
do {
try initializeChat(start: true)
} catch let error {
fatalError("Failed to start or load chats: \(responseError(error))")
}
if ChatModel.shared.currentUser == nil {
completeReceiving("no current user")
return
}
logger.debug("BGManager.receiveMessages: starting chat")
activateChat(appState: .bgRefresh)
let cr = ChatReceiver()
self.chatReceiver = cr
cr.start()
RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in
logger.debug("BGManager.receiveMessages: timer")
self.bgTimer = timer
self.timerCount += 1
if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages {
completeReceiving("timer (no messages after \(waitForMessages) seconds)")
} else if self.timerCount >= maxTimerCount {
completeReceiving("timer (called \(maxTimerCount) times")
}
}, forMode: .default)
}

View File

@@ -10,10 +10,14 @@ import Foundation
import Combine
import SwiftUI
import WebKit
import SimpleXChat
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
@Published var currentUser: User?
@Published var chatRunning: Bool?
@Published var chatDbChanged = false
// list of chat "previews"
@Published var chats: [Chat] = []
// current chat
@@ -25,10 +29,14 @@ final class ChatModel: ObservableObject {
@Published var userAddress: String?
@Published var userSMPServers: [String]?
@Published var appOpenUrl: URL?
@Published var deviceToken: String?
@Published var tokenStatus = NtfTknStatus.new
@Published var deviceToken: DeviceToken?
@Published var savedToken: DeviceToken?
@Published var tokenRegistered = false
@Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off
@Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get()
// current WebRTC call
@Published var callInvitations: Dictionary<ChatId, CallInvitation> = [:]
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@Published var activeCall: Call?
@Published var callCommand: WCallCommand?
@Published var showCallView = false
@@ -86,13 +94,27 @@ final class ChatModel: ObservableObject {
func replaceChat(_ id: String, _ chat: Chat) {
if let i = getChatIndex(id) {
let serverInfo = chats[i].serverInfo
chats[i] = chat
chats[i].serverInfo = serverInfo
} else {
// invalid state, correcting
chats.insert(chat, at: 0)
}
}
func updateChats(with newChats: [ChatData]) {
for c in newChats {
if let chat = getChat(c.id) {
chat.chatInfo = c.chatInfo
chat.chatItems = c.chatItems
chat.chatStats = c.chatStats
} else {
addChat(Chat(c))
}
}
}
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update previews
if let i = getChatIndex(cInfo.id) {
@@ -242,6 +264,7 @@ final class Chat: ObservableObject, Identifiable {
@Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats
@Published var serverInfo = ServerInfo(networkStatus: .unknown)
var created = Date.now
struct ServerInfo: Decodable {
var networkStatus: NetworkStatus
@@ -299,4 +322,6 @@ final class Chat: ObservableObject, Identifiable {
}
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
}

View File

@@ -9,6 +9,7 @@
import Foundation
import UserNotifications
import UIKit
import SimpleXChat
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
@@ -135,12 +136,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification")
),
// TODO remove
UNNotificationCategory(
identifier: ntfCategoryCheckingMessages,
identifier: ntfCategoryConnectionEvent,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notification")
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
)
])
}
@@ -183,21 +183,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
addNotification(createMessageReceivedNtf(cInfo, cItem))
}
func notifyCallInvitation(_ invitation: CallInvitation) {
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation")
addNotification(createCallInvitationNtf(invitation))
}
// TODO remove
func notifyCheckingMessages() {
logger.debug("NtfManager.notifyCheckingMessages")
let content = createNotification(
categoryIdentifier: ntfCategoryCheckingMessages,
title: NSLocalizedString("Checking new messages...", comment: "notification")
)
addNotification(content)
}
private func addNotification(_ content: UNMutableNotificationContent) {
if !granted { return }
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false)

View File

@@ -0,0 +1,41 @@
//
// PushEnvironment.swift
// SimpleX (iOS)
//
// Created by Evgeny on 27/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import SimpleXChat
let pushEnvironment: PushEnvironment = {
guard let provisioningProfile = try? provisioningProfile(),
let entitlements = provisioningProfile["Entitlements"] as? [String: Any],
let environment = entitlements["aps-environment"] as? String,
let env = PushEnvironment(rawValue: environment)
else {
logger.warning("pushEnvironment: unknown, assuming production")
return .production
}
logger.debug("pushEnvironment: \(env.rawValue)")
return env
}()
private func provisioningProfile() throws -> [String: Any]? {
guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") else {
return nil
}
let binaryString = try String(contentsOf: url, encoding: .isoLatin1)
let scanner = Scanner(string: binaryString)
guard scanner.scanUpToString("<plist") != nil,
let plistString = scanner.scanUpToString("</plist>"),
let data = (plistString + "</plist>").data(using: .isoLatin1)
else {
return nil
}
return try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
}

View File

@@ -1,72 +0,0 @@
//
// CallTypes.swift
// SimpleX (iOS)
//
// Created by Evgeny on 05/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
struct WebRTCCallOffer: Encodable {
var callType: CallType
var rtcSession: WebRTCSession
}
struct WebRTCSession: Codable {
var rtcSession: String
var rtcIceCandidates: String
}
struct WebRTCExtraInfo: Codable {
var rtcIceCandidates: String
}
struct CallInvitation {
var contact: Contact
var callkitUUID: UUID?
var peerMedia: CallMediaType
var sharedKey: String?
var callTs: Date
var callTypeText: LocalizedStringKey {
get {
switch peerMedia {
case .video: return sharedKey == nil ? "video call (not e2e encrypted)" : "**e2e encrypted** video call"
case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)" : "**e2e encrypted** audio call"
}
}
}
static let sampleData = CallInvitation(
contact: Contact.sampleData,
peerMedia: .audio,
callTs: .now
)
}
struct CallType: Codable {
var media: CallMediaType
var capabilities: CallCapabilities
}
enum CallMediaType: String, Codable, Equatable {
case video = "video"
case audio = "audio"
}
enum VideoCamera: String, Codable, Equatable {
case user = "user"
case environment = "environment"
}
struct CallCapabilities: Codable, Equatable {
var encryption: Bool
}
enum WebRTCCallStatus: String, Encodable {
case connected = "connected"
case connecting = "connecting"
case disconnected = "disconnected"
case failed = "failed"
}

View File

@@ -1,30 +0,0 @@
//
// GroupDefaults.swift
// SimpleX (iOS)
//
// Created by Evgeny on 26/04/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
func getGroupDefaults() -> UserDefaults? {
UserDefaults(suiteName: "5NN7GUYB6T.group.chat.simplex.app")
}
func setAppState(_ phase: ScenePhase) {
if let defaults = getGroupDefaults() {
defaults.set(phase == .background, forKey: "appInBackground")
defaults.synchronize()
}
}
func getAppState() -> ScenePhase {
if let defaults = getGroupDefaults() {
if defaults.bool(forKey: "appInBackground") {
return .background
}
}
return .active
}

View File

@@ -1,100 +0,0 @@
//
// Notifications.swift
// SimpleX
//
// Created by Evgeny on 28/04/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import UserNotifications
import SwiftUI
let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION"
let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE"
// TODO remove
let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES"
let appNotificationId = "chat.simplex.app.notification"
func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent {
createNotification(
categoryIdentifier: ntfCategoryContactRequest,
title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName),
body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName),
targetContentIdentifier: nil,
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId]
)
}
func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent {
createNotification(
categoryIdentifier: ntfCategoryContactConnected,
title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName),
body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName),
targetContentIdentifier: contact.id
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
)
}
func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent {
createNotification(
categoryIdentifier: ntfCategoryMessageReceived,
title: "\(cInfo.chatViewName):",
body: hideSecrets(cItem),
targetContentIdentifier: cInfo.id
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
)
}
func createCallInvitationNtf(_ invitation: CallInvitation) -> UNMutableNotificationContent {
let text = invitation.peerMedia == .video
? NSLocalizedString("Incoming video call", comment: "notification")
: NSLocalizedString("Incoming audio call", comment: "notification")
return createNotification(
categoryIdentifier: ntfCategoryCallInvitation,
title: "\(invitation.contact.chatViewName):",
body: text,
targetContentIdentifier: nil,
userInfo: ["chatId": invitation.contact.id]
)
}
func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.categoryIdentifier = categoryIdentifier
content.title = title
if let s = subtitle { content.subtitle = s }
if let s = body { content.body = s }
content.targetContentIdentifier = targetContentIdentifier
content.userInfo = userInfo
// TODO move logic of adding sound here, so it applies to background notifications too
content.sound = .default
// content.interruptionLevel = .active
// content.relevanceScore = 0.5 // 0-1
return content
}
func hideSecrets(_ cItem: ChatItem) -> String {
if cItem.content.text != "" {
if let md = cItem.formattedText {
var res = ""
for ft in md {
if case .secret = ft.format {
res = res + "..."
} else {
res = res + ft.text
}
}
return res
} else {
return cItem.content.text
}
} else {
return cItem.file?.fileName ?? ""
}
}

View File

@@ -11,6 +11,7 @@ import UIKit
import Dispatch
import BackgroundTasks
import SwiftUI
import SimpleXChat
private var chatController: chat_ctrl?
@@ -46,7 +47,7 @@ enum TerminalItem: Identifiable {
}
}
private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
var id: UIBackgroundTaskIdentifier!
var running = true
let endTask = {
@@ -71,7 +72,7 @@ private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
let msgDelay: Double = 7.5
let maxTaskDuration: Double = 15
private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) -> ChatResponse {
private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
let endTask = beginBGTask()
DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask)
let r = f()
@@ -92,11 +93,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
if case .apiParseMarkdown = cmd {} else {
DispatchQueue.main.async {
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
ChatModel.shared.terminalItems.append(.resp(.now, resp))
}
DispatchQueue.main.async {
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
ChatModel.shared.terminalItems.append(.resp(.now, resp))
}
return resp
}
@@ -107,10 +106,10 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil
}
}
func chatRecvMsg() async -> ChatResponse {
func chatRecvMsg() async -> ChatResponse? {
await withCheckedContinuation { cont in
_ = withBGTask(bgDelay: msgDelay) {
let resp = chatResponse(chat_recv_msg(getChatCtrl())!)
_ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
let resp = recvSimpleXMsg()
cont.resume(returning: resp)
return resp
}
@@ -134,7 +133,7 @@ func apiCreateActiveUser(_ p: Profile) throws -> User {
}
func apiStartChat() throws -> Bool {
let r = chatSendCmdSync(.startChat)
let r = chatSendCmdSync(.startChat(subscribe: true))
switch r {
case .chatStarted: return true
case .chatRunning: return false
@@ -142,15 +141,47 @@ func apiStartChat() throws -> Bool {
}
}
func apiStopChat() async throws {
let r = await chatSendCmd(.apiStopChat)
switch r {
case .chatStopped: return
default: throw r
}
}
func apiActivateChat() {
let r = chatSendCmdSync(.apiActivateChat)
if case .cmdOk = r { return }
logger.error("apiActivateChat error: \(String(describing: r))")
}
func apiSuspendChat(timeoutMicroseconds: Int) {
let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
if case .cmdOk = r { return }
logger.error("apiSuspendChat error: \(String(describing: r))")
}
func apiSetFilesFolder(filesFolder: String) throws {
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder))
if case .cmdOk = r { return }
throw r
}
func apiGetChats() throws -> [Chat] {
func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config))
}
func apiImportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiImportArchive(config: config))
}
func apiDeleteStorage() async throws {
try await sendCommandOkResp(.apiDeleteStorage)
}
func apiGetChats() throws -> [ChatData] {
let r = chatSendCmdSync(.apiGetChats)
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
if case let .apiChats(chats) = r { return chats }
throw r
}
@@ -160,6 +191,18 @@ func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
throw r
}
func loadChat(chat: Chat) {
do {
let cInfo = chat.chatInfo
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
let m = ChatModel.shared
m.updateChatInfo(chat.chatInfo)
m.chatItems = chat.chatItems
} catch let error {
logger.error("loadChat error: \(responseError(error))")
}
}
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem {
let chatModel = ChatModel.shared
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
@@ -195,21 +238,45 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM
throw r
}
func apiRegisterToken(token: String) async throws -> NtfTknStatus {
let r = await chatSendCmd(.apiRegisterToken(token: token))
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) {
let r = chatSendCmdSync(.apiGetNtfToken)
switch r {
case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode)
case .chatCmdError(.errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off)
default:
logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)")
return (nil, nil, .off)
}
}
func apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) async throws -> NtfTknStatus {
let r = await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode))
if case let .ntfTokenStatus(status) = r { return status }
throw r
}
func apiVerifyToken(token: String, code: String, nonce: String) async throws {
try await sendCommandOkResp(.apiVerifyToken(token: token, code: code, nonce: nonce))
func registerToken(token: DeviceToken) {
let m = ChatModel.shared
let mode = m.notificationMode
if mode != .off && !m.tokenRegistered {
m.tokenRegistered = true
logger.debug("registerToken \(mode.rawValue)")
Task {
do {
let status = try await apiRegisterToken(token: token, notificationMode: mode)
await MainActor.run { m.tokenStatus = status }
} catch let error {
logger.error("registerToken apiRegisterToken error: \(responseError(error))")
}
}
}
}
func apiIntervalNofication(token: String, interval: Int) async throws {
try await sendCommandOkResp(.apiIntervalNofication(token: token, interval: interval))
func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws {
try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code))
}
func apiDeleteToken(token: String) async throws {
func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
@@ -311,12 +378,6 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
}
}
func apiParseMarkdown(text: String) throws -> [FormattedText]? {
let r = chatSendCmdSync(.apiParseMarkdown(text: text))
if case let .apiParsedMarkdown(formattedText) = r { return formattedText }
throw r
}
func apiCreateUserAddress() async throws -> String {
let r = await chatSendCmd(.createMyAddress)
if case let .userContactLinkCreated(connReq) = r { return connReq }
@@ -418,6 +479,12 @@ func apiEndCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiEndCall(contact: contact))
}
func apiGetCallInvitations() throws -> [RcvCallInvitation] {
let r = chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
@@ -453,42 +520,49 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
throw r
}
func initializeChat() {
func initializeChat(start: Bool) throws {
logger.debug("initializeChat")
do {
let m = ChatModel.shared
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
m.onboardingStage = .step1_SimpleXInfo
} else if start {
try startChat()
} else {
startChat()
m.chatRunning = false
}
} catch {
fatalError("Failed to initialize chat controller or database: \(error)")
fatalError("Failed to initialize chat controller or database: \(responseError(error))")
}
}
func startChat() {
func startChat() throws {
logger.debug("startChat")
do {
let m = ChatModel.shared
// TODO set file folder once, before chat is started
let justStarted = try apiStartChat()
if justStarted {
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
m.userAddress = try apiGetUserAddress()
m.userSMPServers = try getUserSMPServers()
m.chats = try apiGetChats()
withAnimation {
m.onboardingStage = m.chats.isEmpty
? .step3_MakeConnection
: .onboardingComplete
}
let m = ChatModel.shared
let justStarted = try apiStartChat()
if justStarted {
m.userAddress = try apiGetUserAddress()
m.userSMPServers = try getUserSMPServers()
let chats = try apiGetChats()
m.chats = chats.map { Chat.init($0) }
try refreshCallInvitations()
(m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken()
if let token = m.deviceToken {
registerToken(token: token)
}
withAnimation {
m.onboardingStage = m.onboardingStage == .step2_CreateProfile
? .step3_SetNotificationsMode
: m.chats.isEmpty
? .step4_MakeConnection
: .onboardingComplete
}
ChatReceiver.shared.start()
} catch {
fatalError("Failed to start or load chats: \(error)")
}
ChatReceiver.shared.start()
m.chatRunning = true
chatLastStartGroupDefault.set(Date.now)
}
class ChatReceiver {
@@ -509,12 +583,13 @@ class ChatReceiver {
}
func receiveMsgLoop() async {
let msg = await chatRecvMsg()
self._lastMsgTime = .now
processReceivedMsg(msg)
// TODO use function that has timeout
if let msg = await chatRecvMsg() {
self._lastMsgTime = .now
await processReceivedMsg(msg)
}
if self.receiveMessages {
do { try await Task.sleep(nanoseconds: 7_500_000) }
catch { logger.error("receiveMsgLoop: Task.sleep error: \(error.localizedDescription)") }
_ = try? await Task.sleep(nanoseconds: 7_500_000)
await receiveMsgLoop()
}
}
@@ -527,9 +602,9 @@ class ChatReceiver {
}
}
func processReceivedMsg(_ res: ChatResponse) {
func processReceivedMsg(_ res: ChatResponse) async {
let m = ChatModel.shared
DispatchQueue.main.async {
await MainActor.run {
m.terminalItems.append(.resp(.now, res))
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
@@ -629,19 +704,9 @@ func processReceivedMsg(_ res: ChatResponse) {
let fileName = cItem.file?.filePath {
removeFile(fileName)
}
case let .callInvitation(contact, callType, sharedKey, callTs):
let uuid = UUID()
var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey, callTs: callTs)
m.callInvitations[contact.id] = invitation
CallController.shared.reportNewIncomingCall(invitation: invitation) { error in
if let error = error {
invitation.callkitUUID = nil
m.callInvitations[contact.id] = invitation
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
logger.debug("reportNewIncomingCall success")
}
}
case let .callInvitation(invitation):
m.callInvitations[invitation.contact.id] = invitation
activateCall(invitation)
// This will be called from notification service extension
// CXProvider.reportNewIncomingVoIPPushPayload([
@@ -681,6 +746,8 @@ func processReceivedMsg(_ res: ChatResponse) {
m.callCommand = .end
// CallController.shared.reportCallRemoteEnded(call: call)
}
case .chatSuspended:
chatSuspended()
default:
logger.debug("unsupported event: \(res.responseType)")
}
@@ -723,6 +790,27 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
m.updateNetworkStatus(contact.id, .error(err))
}
func refreshCallInvitations() throws {
let m = ChatModel.shared
let callInvitations = try apiGetCallInvitations()
m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
if let inv = callInvitations.last {
activateCall(inv)
}
}
func activateCall(_ callInvitation: RcvCallInvitation) {
let m = ChatModel.shared
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
logger.debug("reportNewIncomingCall success")
}
}
}
private struct UserResponse: Decodable {
var user: User?
var error: String?

View File

@@ -0,0 +1,79 @@
//
// SuspendChat.swift
// SimpleX (iOS)
//
// Created by Evgeny on 26/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import UIKit
import SimpleXChat
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
let appSuspendTimeout: Int = 15 // seconds
let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds
private func _suspendChat(timeout: Int) {
appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
}
func suspendChat() {
suspendLockQueue.sync {
if appStateGroupDefault.get() != .stopped {
_suspendChat(timeout: appSuspendTimeout)
}
}
}
func suspendBgRefresh() {
suspendLockQueue.sync {
if case .bgRefresh = appStateGroupDefault.get() {
_suspendChat(timeout: bgSuspendTimeout)
}
}
}
func terminateChat() {
suspendLockQueue.sync {
switch appStateGroupDefault.get() {
case .suspending:
// suspend instantly if already suspending
_chatSuspended()
apiSuspendChat(timeoutMicroseconds: 0)
case .stopped: ()
default:
_suspendChat(timeout: terminationTimeout)
}
}
}
func chatSuspended() {
suspendLockQueue.sync {
if case .suspending = appStateGroupDefault.get() {
_chatSuspended()
}
}
}
private func _chatSuspended() {
logger.debug("_chatSuspended")
appStateGroupDefault.set(.suspended)
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop()
}
}
func activateChat(appState: AppState = .active) {
suspendLockQueue.sync {
appStateGroupDefault.set(appState)
apiActivateChat()
}
}

View File

@@ -1,11 +0,0 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
extern void hs_init(int argc, char **argv[]);
typedef void* chat_ctrl;
extern chat_ctrl chat_init(char *path);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);

View File

@@ -7,6 +7,7 @@
import SwiftUI
import OSLog
import SimpleXChat
let logger = Logger()
@@ -24,6 +25,7 @@ struct SimpleXApp: App {
init() {
hs_init(0, nil)
UserDefaults.standard.register(defaults: appDefaults)
setDbContainer()
BGManager.shared.register()
NtfManager.shared.registerCategories()
}
@@ -37,19 +39,33 @@ struct SimpleXApp: App {
chatModel.appOpenUrl = url
}
.onAppear() {
initializeChat()
do {
chatModel.v3DBMigration = v3DBMigrationDefault.get()
try initializeChat(start: chatModel.v3DBMigration.startChat)
} catch let error {
fatalError("Failed to start or load chats: \(responseError(error))")
}
}
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase \(String(describing: scenePhase))")
setAppState(phase)
switch (phase) {
case .background:
suspendChat()
BGManager.shared.schedule()
if userAuthorized == true {
enteredBackground = ProcessInfo.processInfo.systemUptime
}
doAuthenticate = false
case .active:
if chatModel.chatRunning == true {
ChatReceiver.shared.start()
}
let appState = appStateGroupDefault.get()
activateChat()
if appState.inactive && chatModel.chatRunning == true {
updateChats()
updateCallInvitations()
}
doAuthenticate = authenticationExpired()
default:
break
@@ -58,6 +74,30 @@ struct SimpleXApp: App {
}
}
private func setDbContainer() {
// Uncomment and run once to open DB in app documents folder:
// dbContainerGroupDefault.set(.documents)
// v3DBMigrationDefault.set(.offer)
// to create database in app documents folder also uncomment:
// let legacyDatabase = true
let legacyDatabase = hasLegacyDatabase()
if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
dbContainerGroupDefault.set(.documents)
setMigrationState(.offer)
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db")
} else {
dbContainerGroupDefault.set(.group)
setMigrationState(.ready)
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db")
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present")
}
}
private func setMigrationState(_ state: V3DBMigrationState) {
if case .migrated = v3DBMigrationDefault.get() { return }
v3DBMigrationDefault.set(state)
}
private func authenticationExpired() -> Bool {
if let enteredBackground = enteredBackground {
return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30
@@ -65,4 +105,31 @@ struct SimpleXApp: App {
return true
}
}
private func updateChats() {
do {
let chats = try apiGetChats()
chatModel.updateChats(with: chats)
if let id = chatModel.chatId,
let chat = chatModel.getChat(id) {
loadChat(chat: chat)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if chatModel.chatId == chat.id {
Task { await markChatRead(chat) }
}
}
}
} catch let error {
logger.error("apiGetChats: cannot update chats \(responseError(error))")
}
}
private func updateCallInvitations() {
do {
try refreshCallInvitations()
}
catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
}
}
}

View File

@@ -8,6 +8,7 @@
import SwiftUI
import WebKit
import SimpleXChat
struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel

View File

@@ -9,6 +9,7 @@
import Foundation
//import CallKit
import AVFoundation
import SimpleXChat
//class CallController: NSObject, CXProviderDelegate, ObservableObject {
class CallController: NSObject, ObservableObject {
@@ -17,7 +18,7 @@ class CallController: NSObject, ObservableObject {
// private let provider = CXProvider(configuration: CallController.configuration)
// private let controller = CXCallController()
private let callManager = CallManager()
@Published var activeCallInvitation: CallInvitation?
@Published var activeCallInvitation: RcvCallInvitation?
// PKPushRegistry will be used from notification service extension
// let registry = PKPushRegistry(queue: nil)
@@ -119,9 +120,8 @@ class CallController: NSObject, ObservableObject {
// }
// }
func reportNewIncomingCall(invitation: CallInvitation, completion: @escaping (Error?) -> Void) {
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
logger.debug("CallController.reportNewIncomingCall")
if !UserDefaults.standard.bool(forKey: DEFAULT_EXPERIMENTAL_CALLS) { return }
// if CallController.useCallKit, let uuid = invitation.callkitUUID {
// let update = CXCallUpdate()
// update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName)
@@ -141,7 +141,7 @@ class CallController: NSObject, ObservableObject {
// }
// }
func reportCallRemoteEnded(invitation: CallInvitation) {
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
// if CallController.useCallKit, let uuid = invitation.callkitUUID {
// provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
// } else if invitation.contact.id == activeCallInvitation?.contact.id {
@@ -171,7 +171,7 @@ class CallController: NSObject, ObservableObject {
}
}
func answerCall(invitation: CallInvitation) {
func answerCall(invitation: RcvCallInvitation) {
callManager.answerIncomingCall(invitation: invitation)
if invitation.contact.id == self.activeCallInvitation?.contact.id {
self.activeCallInvitation = nil
@@ -192,7 +192,7 @@ class CallController: NSObject, ObservableObject {
// }
}
func endCall(invitation: CallInvitation) {
func endCall(invitation: RcvCallInvitation) {
callManager.endCall(invitation: invitation) {
if invitation.contact.id == self.activeCallInvitation?.contact.id {
DispatchQueue.main.async {

View File

@@ -7,6 +7,7 @@
//
import Foundation
import SimpleXChat
class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
@@ -33,7 +34,7 @@ class CallManager {
return false
}
func answerIncomingCall(invitation: CallInvitation) {
func answerIncomingCall(invitation: RcvCallInvitation) {
let m = ChatModel.shared
m.callInvitations.removeValue(forKey: invitation.contact.id)
m.activeCall = Call(
@@ -41,13 +42,13 @@ class CallManager {
contact: invitation.contact,
callkitUUID: invitation.callkitUUID,
callState: .invitationAccepted,
localMedia: invitation.peerMedia,
localMedia: invitation.callType.media,
sharedKey: invitation.sharedKey
)
m.showCallView = true
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
logger.debug("answerIncomingCall useRelay \(useRelay)")
m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay)
m.callCommand = .start(media: invitation.callType.media, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay)
}
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
@@ -85,7 +86,7 @@ class CallManager {
}
}
func endCall(invitation: CallInvitation, completed: @escaping () -> Void) {
func endCall(invitation: RcvCallInvitation, completed: @escaping () -> Void) {
ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id)
Task {
do {
@@ -97,7 +98,7 @@ class CallManager {
}
}
private func getCallInvitation(_ callUUID: UUID) -> CallInvitation? {
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
return invitation
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct IncomingCallView: View {
@EnvironmentObject var m: ChatModel
@@ -25,10 +26,10 @@ struct IncomingCallView: View {
}
}
private func incomingCall(_ invitation: CallInvitation) -> some View {
private func incomingCall(_ invitation: RcvCallInvitation) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: invitation.peerMedia == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
Text(invitation.callTypeText)
}
HStack {
@@ -80,7 +81,7 @@ struct IncomingCallView: View {
struct IncomingCallView_Previews: PreviewProvider {
static var previews: some View {
CallController.shared.activeCallInvitation = CallInvitation.sampleData
CallController.shared.activeCallInvitation = RcvCallInvitation.sampleData
return IncomingCallView()
}
}

View File

@@ -8,6 +8,7 @@
import Foundation
import SwiftUI
import SimpleXChat
class Call: ObservableObject, Equatable {
static func == (lhs: Call, rhs: Call) -> Bool {

View File

@@ -8,6 +8,7 @@
import SwiftUI
import WebKit
import SimpleXChat
class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate {
var rtcWebView: Binding<WKWebView?>

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct CICallItemView: View {
@EnvironmentObject var m: ChatModel

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct CIFileView: View {
@Environment(\.colorScheme) var colorScheme
@@ -133,7 +134,7 @@ struct CIFileView: View {
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let sentFile = ChatItem(
let sentFile: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
content: .sndMsgContent(msgContent: .file("")),

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct CIImageView: View {
@Environment(\.colorScheme) var colorScheme

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct CILinkView: View {
@Environment(\.colorScheme) var colorScheme

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct CIMetaView: View {
var chatItem: ChatItem

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct DeletedItemView: View {
var chatItem: ChatItem

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct EmojiItemView: View {
var chatItem: ChatItem

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct IntegrityErrorItemView: View {
var chatItem: ChatItem

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let linkColor = Color(uiColor: uiLinkColor)

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ChatItemView: View {
var chatInfo: ChatInfo

View File

@@ -7,13 +7,13 @@
//
import SwiftUI
import SimpleXChat
private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false
@ObservedObject var chat: Chat
@Binding var showChatInfo: Bool
@State private var composeState = ComposeState()
@@ -107,7 +107,7 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if enableCalls, case let .direct(contact) = cInfo {
if case let .direct(contact) = cInfo {
HStack {
callButton(contact, .audio, imageName: "phone")
callButton(contact, .video, imageName: "video")
@@ -224,7 +224,7 @@ struct ChatView: View {
}
func markAllRead() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if chatModel.chatId == chat.id {
Task { await markChatRead(chat) }
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ComposeImageView: View {
@Environment(\.colorScheme) var colorScheme

View File

@@ -8,7 +8,7 @@
import SwiftUI
import LinkPresentation
import SimpleXChat
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
enum ComposePreview {
case noPreview
@@ -209,9 +210,8 @@ struct ComposeView: View {
allowedContentTypes: [.data],
allowsMultipleSelection: false
) { result in
if case .success = result {
if case let .success(files) = result, let fileURL = files.first {
do {
let fileURL: URL = try result.get().first!
var fileSize: Int? = nil
if fileURL.startAccessingSecurityScopedResource() {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
@@ -391,17 +391,12 @@ struct ComposeView: View {
}
private func parseMessage(_ msg: String) -> URL? {
do {
let parsedMsg = try apiParseMarkdown(text: msg)
let uri = parsedMsg?.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
})
if let uri = uri { return URL(string: uri.text) }
else { return nil }
} catch {
logger.error("apiParseMarkdown error: \(error.localizedDescription)")
return nil
}
let parsedMsg = parseSimpleXMarkdown(msg)
let uri = parsedMsg?.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
})
if let uri = uri { return URL(string: uri.text) }
else { return nil }
}
private func isSimplexLink(_ link: String) -> Bool {

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct SendMessageView: View {
@Binding var composeState: ComposeState

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@@ -29,16 +30,7 @@ struct ChatListNavLink: View {
private func chatView() -> some View {
ChatView(chat: chat, showChatInfo: $showChatInfo)
.onAppear {
do {
let cInfo = chat.chatInfo
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
chatModel.updateChatInfo(chat.chatInfo)
chatModel.chatItems = chat.chatItems
} catch {
logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)")
}
}
.onAppear { loadChat(chat: chat) }
}
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@@ -19,9 +20,10 @@ struct ChatListView: View {
var body: some View {
let v = NavigationView {
List {
ForEach(filteredChats()) { chat in
ForEach(filteredChats(), id: \.viewId) { chat in
ChatListNavLink(chat: chat, showChatInfo: $showChatInfo)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true)
}
}
.onChange(of: chatModel.chatId) { _ in
@@ -32,7 +34,7 @@ struct ChatListView: View {
}
.onChange(of: chatModel.chats.isEmpty) { empty in
if !empty { return }
withAnimation { chatModel.onboardingStage = .step3_MakeConnection }
withAnimation { chatModel.onboardingStage = .step4_MakeConnection }
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onAppear() { connectViaUrl() }
@@ -45,7 +47,11 @@ struct ChatListView: View {
SettingsButton()
}
ToolbarItem(placement: .navigationBarTrailing) {
NewChatButton()
switch chatModel.chatRunning {
case .some(true): NewChatButton()
case .some(false): chatStoppedIcon()
case .none: EmptyView()
}
}
}
}
@@ -73,6 +79,17 @@ struct ChatListView: View {
}
}
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
title: "Chat is stopped",
message: "You can start chat via app Settings / Database or by restarting the app"
)
} label: {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
}
}
struct ChatListView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ChatPreviewView: View {
@ObservedObject var chat: Chat

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ContactConnectionView: View {
var contactConnection: PendingContactConnection

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ContactRequestView: View {
var contactRequest: UserContactRequest

View File

@@ -0,0 +1,65 @@
//
// ChatArchiveView.swift
// SimpleXChat
//
// Created by Evgeny on 23/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChatArchiveView: View {
var archiveName: String
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
@State private var showDeleteAlert = false
var body: some View {
let fileUrl = getDocumentsDirectory().appendingPathComponent(archiveName)
let fileTs = chatArchiveTimeDefault.get()
List {
Section {
settingsRow("square.and.arrow.up") {
Button {
showShareSheet(items: [fileUrl])
} label: {
Text("Save archive")
}
}
settingsRow("trash") {
Button {
showDeleteAlert = true
} label: {
Text("Delete archive").foregroundColor(.red)
}
}
} header: {
Text("Chat archive")
} footer: {
Text("Created on \(fileTs)")
}
}
.alert(isPresented: $showDeleteAlert) {
Alert(
title: Text("Delete chat archive?"),
primaryButton: .destructive(Text("Delete")) {
do {
try FileManager.default.removeItem(atPath: fileUrl.path)
chatArchiveName = nil
chatArchiveTime = 0
} catch let error {
logger.error("removeItem error \(String(describing: error))")
}
},
secondaryButton: .cancel()
)
}
}
}
struct ChatArchiveView_Previews: PreviewProvider {
static var previews: some View {
ChatArchiveView(archiveName: "")
}
}

View File

@@ -0,0 +1,332 @@
//
// DatabaseView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 19/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum DatabaseAlert: Identifiable {
case stopChat
case importArchive
case archiveImported
case deleteChat
case chatDeleted
case deleteLegacyDatabase
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
case .stopChat: return "stopChat"
case .importArchive: return "importArchive"
case .archiveImported: return "archiveImported"
case .deleteChat: return "deleteChat"
case .chatDeleted: return "chatDeleted"
case .deleteLegacyDatabase: return "deleteLegacyDatabase"
case let .error(title, _): return "error \(title)"
}
}
}
struct DatabaseView: View {
@EnvironmentObject var m: ChatModel
@Binding var showSettings: Bool
@State private var runChat = false
@State private var alert: DatabaseAlert? = nil
@State private var showFileImporter = false
@State private var importedArchivePath: URL?
@State private var progressIndicator = false
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
@State private var dbContainer = dbContainerGroupDefault.get()
@State private var legacyDatabase = hasLegacyDatabase()
var body: some View {
ZStack {
chatDatabaseView()
if progressIndicator {
ProgressView().scaleEffect(2)
}
}
}
private func chatDatabaseView() -> some View {
List {
let stopped = m.chatRunning == false
Section {
settingsRow(
stopped ? "exclamationmark.octagon.fill" : "play.fill",
color: stopped ? .red : .green
) {
Toggle(
stopped ? "Chat is stopped" : "Chat is running",
isOn: $runChat
)
.onChange(of: runChat) { _ in
if (runChat) {
startChat()
} else {
alert = .stopChat
}
}
}
} header: {
Text("Run chat")
} footer: {
if case .documents = dbContainer {
Text("Database will be migrated when the app restarts")
}
}
Section {
settingsRow("square.and.arrow.up") {
Button {
exportArchive()
} label: {
Text("Export database")
}
}
settingsRow("square.and.arrow.down") {
Button(role: .destructive) {
showFileImporter = true
} label: {
Text("Import database")
}
}
if let archiveName = chatArchiveName {
let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
? "Old database archive"
: "New database archive"
settingsRow("archivebox") {
NavigationLink {
ChatArchiveView(archiveName: archiveName)
.navigationTitle(title)
} label: {
Text(title)
}
}
}
settingsRow("trash.slash") {
Button(role: .destructive) {
alert = .deleteChat
} label: {
Text("Delete database")
}
}
} header: {
Text("Chat database")
} footer: {
Text(
stopped
? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
: "Stop chat to enable database actions"
)
}
.disabled(!stopped)
if case .group = dbContainer, legacyDatabase {
Section("Old database") {
settingsRow("trash") {
Button {
alert = .deleteLegacyDatabase
} label: {
Text("Delete old database")
}
}
}
}
}
.onAppear { runChat = m.chatRunning ?? true }
.alert(item: $alert) { item in databaseAlert(item) }
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.zip],
allowsMultipleSelection: false
) { result in
if case let .success(files) = result, let fileURL = files.first {
importedArchivePath = fileURL
alert = .importArchive
}
}
}
private func databaseAlert(_ alertItem: DatabaseAlert) -> Alert {
switch alertItem {
case .stopChat:
return Alert(
title: Text("Stop chat?"),
message: Text("Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped."),
primaryButton: .destructive(Text("Stop")) {
stopChat()
},
secondaryButton: .cancel {
withAnimation { runChat = true }
}
)
case .importArchive:
if let fileURL = importedArchivePath {
return Alert(
title: Text("Import chat database?"),
message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Import")) {
importArchive(fileURL)
},
secondaryButton: .cancel()
)
} else {
return Alert(title: Text("Error: no database file"))
}
case .archiveImported:
return Alert(
title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database")
)
case .deleteChat:
return Alert(
title: Text("Delete chat profile?"),
message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Delete")) {
deleteChat()
},
secondaryButton: .cancel()
)
case .chatDeleted:
return Alert(
title: Text("Chat database deleted"),
message: Text("Restart the app to create a new chat profile")
)
case .deleteLegacyDatabase:
return Alert(
title: Text("Delete old database?"),
message: Text("The old database was not removed during the migration, it can be deleted."),
primaryButton: .destructive(Text("Delete")) {
deleteLegacyDatabase()
},
secondaryButton: .cancel()
)
case let .error(title, error):
return Alert(title: Text(title), message: Text("\(error)"))
}
}
private func stopChat() {
Task {
do {
try await apiStopChat()
ChatReceiver.shared.stop()
await MainActor.run { m.chatRunning = false }
appStateGroupDefault.set(.stopped)
} catch let error {
await MainActor.run {
runChat = true
alert = .error(title: "Error stopping chat", error: responseError(error))
}
}
}
}
private func exportArchive() {
progressIndicator = true
Task {
do {
let archivePath = try await exportChatArchive()
showShareSheet(items: [archivePath])
await MainActor.run { progressIndicator = false }
} catch let error {
await MainActor.run {
alert = .error(title: "Error exporting chat database", error: responseError(error))
progressIndicator = false
}
}
}
}
private func importArchive(_ archivePath: URL) {
if archivePath.startAccessingSecurityScopedResource() {
progressIndicator = true
Task {
do {
try await apiDeleteStorage()
do {
let config = ArchiveConfig(archivePath: archivePath.path)
try await apiImportArchive(config: config)
await operationEnded(.archiveImported)
} catch let error {
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)))
}
} catch let error {
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)))
}
archivePath.stopAccessingSecurityScopedResource()
}
} else {
alert = .error(title: "Error accessing database file")
}
}
private func deleteChat() {
progressIndicator = true
Task {
do {
try await apiDeleteStorage()
await operationEnded(.chatDeleted)
} catch let error {
await operationEnded(.error(title: "Error deleting database", error: responseError(error)))
}
}
}
private func deleteLegacyDatabase() {
if removeLegacyDatabaseAndFiles() {
legacyDatabase = false
} else {
alert = .error(title: "Error deleting old database")
}
}
private func operationEnded(_ dbAlert: DatabaseAlert) async {
await MainActor.run {
m.chatDbChanged = true
progressIndicator = false
alert = dbAlert
}
}
private func startChat() {
if m.chatDbChanged {
showSettings = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resetChatCtrl()
do {
try initializeChat(start: true)
m.chatDbChanged = false
appStateGroupDefault.set(.active)
} catch let error {
fatalError("Error starting chat \(responseError(error))")
}
}
} else {
do {
_ = try apiStartChat()
runChat = true
m.chatRunning = true
ChatReceiver.shared.start()
chatLastStartGroupDefault.set(Date.now)
appStateGroupDefault.set(.active)
} catch let error {
runChat = false
alert = .error(title: "Error starting chat", error: responseError(error))
}
}
}
}
struct DatabaseView_Previews: PreviewProvider {
static var previews: some View {
DatabaseView(showSettings: Binding.constant(false))
}
}

View File

@@ -0,0 +1,251 @@
//
// MigrateToGroupView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 20/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum V3DBMigrationState: String {
case offer
case postponed
case exporting
case export_error
case exported
case migrating
case migration_error
case migrated
case ready
var startChat: Bool {
switch self {
case .postponed: return true
case .ready: return true
default: return false
}
}
}
let v3DBMigrationDefault = EnumDefault<V3DBMigrationState>(
defaults: UserDefaults.standard,
forKey: DEFAULT_CHAT_V3_DB_MIGRATION,
withDefault: .offer
)
struct MigrateToAppGroupView: View {
@EnvironmentObject var chatModel: ChatModel
@State private var migrationError = ""
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
var body: some View {
ZStack(alignment: .topLeading) {
Text("Push notifications").font(.largeTitle)
switch chatModel.v3DBMigration {
case .offer:
VStack(alignment: .leading, spacing: 16) {
Text("To support instant push notifications the chat database has to be migrated.")
Text("If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app).")
}
.padding(.top, 56)
center {
Button {
migrateDatabaseToV3()
} label: {
Text("Start migration")
.font(.title)
.frame(maxWidth: .infinity)
}
}
skipMigration()
case .exporting:
center {
ProgressView(value: 0.33)
Text("Exporting database archive...")
}
migrationProgress()
case .export_error:
migrationFailed().padding(.top, 56)
center {
Text("Export error:").font(.headline)
Text(migrationError)
}
skipMigration()
case .exported:
center {
Text("Exported database archive.")
}
case .migrating:
center {
ProgressView(value: 0.67)
Text("Migrating database archive...")
}
migrationProgress()
case .migration_error:
VStack(alignment: .leading, spacing: 16) {
migrationFailed()
Text("The created archive is available via app Settings / Database / Old database archive.")
}
.padding(.top, 56)
center {
Text("Migration error:").font(.headline)
Text(migrationError)
}
skipMigration()
case .migrated:
center {
ProgressView(value: 1.0)
Text("Migration is completed")
}
VStack {
Spacer()
Spacer()
Spacer()
Button {
do {
resetChatCtrl()
try initializeChat(start: true)
chatModel.onboardingStage = .step3_SetNotificationsMode
setV3DBMigration(.ready)
} catch let error {
dbContainerGroupDefault.set(.documents)
setV3DBMigration(.migration_error)
migrationError = "Error starting chat: \(responseError(error))"
}
deleteOldArchive()
} label: {
Text("Start chat")
.font(.title)
.frame(maxWidth: .infinity)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
default:
Spacer()
Text("Unexpected migration state")
Text("\(chatModel.v3DBMigration.rawValue)")
Spacer()
skipMigration()
}
}
.padding()
}
private func center<Content>(@ViewBuilder c: @escaping () -> Content) -> some View where Content: View {
VStack(alignment: .leading, spacing: 8) { c() }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
private func migrationProgress() -> some View {
VStack {
Spacer()
ProgressView().scaleEffect(2)
Spacer()
Spacer()
Spacer()
}
.frame(maxWidth: .infinity)
}
private func migrationFailed() -> some View {
Text("Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat).")
}
private func skipMigration() -> some View {
ZStack {
Button {
setV3DBMigration(.postponed)
do {
try startChat()
} catch let error {
fatalError("Failed to start or load chats: \(responseError(error))")
}
} label: {
Text("Do it later")
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
}
private func setV3DBMigration(_ state: V3DBMigrationState) {
chatModel.v3DBMigration = state
v3DBMigrationDefault.set(state)
}
func migrateDatabaseToV3() {
setV3DBMigration(.exporting)
let archiveTime = Date.now
let archiveName = "simplex-chat.\(archiveTime.ISO8601Format()).zip"
chatArchiveTime = archiveTime.timeIntervalSince1970
chatArchiveName = archiveName
let config = ArchiveConfig(archivePath: getDocumentsDirectory().appendingPathComponent(archiveName).path)
Task {
do {
try await apiExportArchive(config: config)
await MainActor.run { setV3DBMigration(.exported) }
} catch let error {
await MainActor.run {
setV3DBMigration(.export_error)
migrationError = responseError(error)
}
return
}
do {
await MainActor.run { setV3DBMigration(.migrating) }
dbContainerGroupDefault.set(.group)
resetChatCtrl()
try await MainActor.run { try initializeChat(start: false) }
try await apiImportArchive(config: config)
await MainActor.run { setV3DBMigration(.migrated) }
} catch let error {
dbContainerGroupDefault.set(.documents)
await MainActor.run {
setV3DBMigration(.migration_error)
migrationError = responseError(error)
}
}
}
}
}
func exportChatArchive() async throws -> URL {
let archiveTime = Date.now
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
let archiveName = "simplex-chat.\(ts).zip"
let archivePath = getDocumentsDirectory().appendingPathComponent(archiveName)
let config = ArchiveConfig(archivePath: archivePath.path)
try await apiExportArchive(config: config)
deleteOldArchive()
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
chatArchiveTimeDefault.set(archiveTime)
return archivePath
}
func deleteOldArchive() {
let d = UserDefaults.standard
if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) {
do {
try FileManager.default.removeItem(atPath: getDocumentsDirectory().appendingPathComponent(archiveName).path)
d.set(nil, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
d.set(0, forKey: DEFAULT_CHAT_ARCHIVE_TIME)
} catch let error {
logger.error("removeItem error \(String(describing: error))")
}
}
}
struct MigrateToGroupView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.v3DBMigration = .migrated
return MigrateToAppGroupView()
.environmentObject(chatModel)
}
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ChatInfoImage: View {
@ObservedObject var chat: Chat

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct ProfileImage: View {
var imageStr: String? = nil

View File

@@ -12,25 +12,27 @@ import CoreImage.CIFilterBuiltins
struct AddContactView: View {
var connReqInvitation: String
var body: some View {
VStack(alignment: .leading) {
Text("One-time invitation link")
.font(.title)
.padding(.vertical)
Text("Your contact can scan it from the app")
.padding(.bottom)
QRCode(uri: connReqInvitation)
.padding(.bottom)
Text("If you can't meet in person, **show QR code in the video call**, or share the link.")
.padding(.bottom)
Button {
showShareSheet(items: [connReqInvitation])
} label: {
Label("Share invitation link", systemImage: "square.and.arrow.up")
ScrollView {
VStack(alignment: .leading) {
Text("One-time invitation link")
.font(.title)
.padding(.vertical)
Text("Your contact can scan it from the app")
.padding(.bottom)
QRCode(uri: connReqInvitation)
.padding(.bottom)
Text("If you can't meet in person, **show QR code in the video call**, or share the link.")
.padding(.bottom)
Button {
showShareSheet(items: [connReqInvitation])
} label: {
Label("Share invitation link", systemImage: "square.and.arrow.up")
}
.frame(maxWidth: .infinity, alignment: .center)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
enum NewChatAction: Identifiable {
case createLink

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct CreateProfile: View {
@EnvironmentObject var m: ChatModel
@@ -94,11 +95,11 @@ struct CreateProfile: View {
)
do {
m.currentUser = try apiCreateActiveUser(profile)
startChat()
withAnimation { m.onboardingStage = .step3_MakeConnection }
try startChat()
withAnimation { m.onboardingStage = .step3_SetNotificationsMode }
} catch {
fatalError("Failed to create user: \(error)")
fatalError("Failed to create user or start chat: \(responseError(error))")
}
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct MakeConnection: View {
@EnvironmentObject var m: ChatModel
@@ -15,7 +16,14 @@ struct MakeConnection: View {
var body: some View {
VStack(alignment: .leading) {
SettingsButton().padding(.bottom, 1)
HStack {
SettingsButton()
if m.chatRunning == false {
Spacer()
chatStoppedIcon()
}
}
.padding(.bottom, 1)
if let user = m.currentUser {
Text("Welcome \(user.displayName)!")
@@ -65,6 +73,7 @@ struct MakeConnection: View {
}
}
}
.disabled(m.chatRunning != true)
}
Spacer()

View File

@@ -15,7 +15,8 @@ struct OnboardingView: View {
switch onboarding {
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
case .step2_CreateProfile: CreateProfile()
case .step3_MakeConnection: MakeConnection()
case .step3_SetNotificationsMode: SetNotificationsMode()
case .step4_MakeConnection: MakeConnection()
case .onboardingComplete: EmptyView()
}
}
@@ -24,7 +25,8 @@ struct OnboardingView: View {
enum OnboardingStage {
case step1_SimpleXInfo
case step2_CreateProfile
case step3_MakeConnection
case step3_SetNotificationsMode
case step4_MakeConnection
case onboardingComplete
}

View File

@@ -0,0 +1,112 @@
//
// NotificationsModeView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 03/07/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SetNotificationsMode: View {
@EnvironmentObject var m: ChatModel
@State private var notificationMode = NotificationsMode.instant
@State private var showAlert: NotificationAlert?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Push notifications").font(.largeTitle)
Text("Send notifications:")
ForEach(NotificationsMode.values) { mode in
NtfModeSelector(mode: mode, selection: $notificationMode)
}
Spacer()
Button {
if let token = m.deviceToken {
setNotificationsMode(token, notificationMode)
} else {
AlertManager.shared.showAlertMsg(title: "No device token!")
}
m.onboardingStage = m.chats.isEmpty
? .step4_MakeConnection
: .onboardingComplete
} label: {
if case .off = notificationMode {
Text("Use chat")
} else {
Text("Enable notifications")
}
}
.font(.title)
.frame(maxWidth: .infinity)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
}
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
switch mode {
case .off:
m.tokenStatus = .new
m.notificationMode = .off
default:
Task {
do {
let status = try await apiRegisterToken(token: token, notificationMode: mode)
await MainActor.run {
m.tokenStatus = status
m.notificationMode = mode
}
} catch let error {
AlertManager.shared.showAlertMsg(
title: "Error enabling notifications",
message: "\(responseError(error))"
)
}
}
}
}
}
struct NtfModeSelector: View {
var mode: NotificationsMode
@Binding var selection: NotificationsMode
@State private var tapped = false
var body: some View {
ZStack {
VStack(alignment: .leading, spacing: 4) {
Text(mode.label)
.font(.headline)
.foregroundColor(selection == mode ? .accentColor : .secondary)
Text(ntfModeDescription(mode))
.lineLimit(10)
.font(.subheadline)
}
.padding(12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: tapped ? .secondarySystemFill : .systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(selection == mode ? Color.accentColor : Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
._onButtonGesture { down in
tapped = down
if down { selection = mode }
} perform: {}
}
}
struct NotificationsModeView_Previews: PreviewProvider {
static var previews: some View {
SetNotificationsMode()
}
}

View File

@@ -77,7 +77,7 @@ struct OnboardingActionButton: View {
if m.currentUser == nil {
actionButton("Create your profile", onboarding: .step2_CreateProfile)
} else {
actionButton("Make a private connection", onboarding: .step3_MakeConnection)
actionButton("Make a private connection", onboarding: .step4_MakeConnection)
}
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
private let terminalFont = Font.custom("Menlo", size: 16)

View File

@@ -21,10 +21,9 @@ struct CallSettings: View {
Section("Limitations") {
VStack(alignment: .leading, spacing: 8) {
textListItem("1.", "Do NOT use SimpleX for emergency calls.")
textListItem("2.", "Pre-arrange the calls, as notifications arrive with a delay (we are improving it).")
textListItem("3.", "The microphone does not work when the app is in the background.")
textListItem("4.", "To prevent the call interruption, enable Do Not Disturb mode.")
textListItem("5.", "If the video fails to connect, flip the camera to resolve it.")
textListItem("2.", "The microphone does not work when the app is in the background.")
textListItem("3.", "To prevent the call interruption, enable Do Not Disturb mode.")
textListItem("4.", "If the video fails to connect, flip the camera to resolve it.")
}
.font(.callout)
.padding(.vertical, 8)

View File

@@ -0,0 +1,222 @@
//
// NotificationsView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 26/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct NotificationsView: View {
@EnvironmentObject var m: ChatModel
@State private var notificationMode: NotificationsMode?
@State private var showAlert: NotificationAlert?
@State private var legacyDatabase = dbContainerGroupDefault.get() == .documents
var body: some View {
List {
Section {
NavigationLink {
List {
Section {
SelectionListView(list: NotificationsMode.values, selection: $notificationMode) { mode in
showAlert = .setMode(mode: mode)
}
} footer: {
VStack(alignment: .leading) {
if let mode = notificationMode {
Text(ntfModeDescription(mode))
}
}
.font(.callout)
.padding(.top, 1)
}
}
.navigationTitle("Send notifications")
.navigationBarTitleDisplayMode(.inline)
.alert(item: $showAlert) { alert in
if let token = m.deviceToken {
return notificationAlert(alert, token)
} else {
return Alert(title: Text("No device token!"))
}
}
.onAppear { notificationMode = m.notificationMode }
} label: {
HStack {
Text("Send notifications")
Spacer()
Text(m.notificationMode.label)
}
}
NavigationLink {
List {
Section {
SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in
ntfPreviewModeGroupDefault.set(previewMode)
m.notificationPreview = previewMode
}
} footer: {
VStack(alignment: .leading, spacing: 1) {
Text("You can set lock screen notification preview via settings.")
Button("Open Settings") {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
}
}
}
}
.navigationTitle("Show preview")
.navigationBarTitleDisplayMode(.inline)
} label: {
HStack {
Text("Show preview")
Spacer()
Text(m.notificationPreview?.label ?? "")
}
}
} header: {
Text("Push notifications")
} footer: {
if legacyDatabase {
Text("Please restart the app and migrate the database to enable push notifications.")
.font(.callout)
.padding(.top, 1)
}
}
.disabled(legacyDatabase)
}
}
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
switch alert {
case let .setMode(mode):
return Alert(
title: Text(ntfModeAlertTitle(mode)),
message: Text(ntfModeDescription(mode)),
primaryButton: .default(Text(mode == .off ? "Turn off" : "Enable")) {
setNotificationsMode(token, mode)
},
secondaryButton: .cancel() {
notificationMode = m.notificationMode
}
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
}
}
private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey {
switch mode {
case .off: return "Turn off notifications?"
case .periodic: return "Enable periodic notifications?"
case .instant: return "Enable instant notifications?"
}
}
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
Task {
switch mode {
case .off:
do {
try await apiDeleteToken(token: token)
await MainActor.run {
m.tokenStatus = .new
notificationMode = .off
m.notificationMode = .off
}
} catch let error {
await MainActor.run {
let err = responseError(error)
logger.error("apiDeleteToken error: \(err)")
showAlert = .error(title: "Error deleting token", error: err)
}
}
default:
do {
let status = try await apiRegisterToken(token: token, notificationMode: mode)
await MainActor.run {
m.tokenStatus = status
notificationMode = mode
m.notificationMode = mode
}
} catch let error {
await MainActor.run {
let err = responseError(error)
logger.error("apiRegisterToken error: \(err)")
showAlert = .error(title: "Error enabling notifications", error: err)
}
}
}
}
}
}
func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
switch mode {
case .off: return "**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)."
case .periodic: return "**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have."
case .instant: return "**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from."
}
}
struct SelectionListView<Item: SelectableItem>: View {
var list: [Item]
@Binding var selection: Item?
var onSelection: ((Item) -> Void)?
@State private var tapped: Item? = nil
var body: some View {
ForEach(list) { item in
HStack {
Text(item.label)
Spacer()
if selection == item {
Image(systemName: "checkmark")
.resizable().scaledToFit().frame(width: 16)
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
.listRowBackground(Color(uiColor: tapped == item ? .secondarySystemFill : .systemBackground))
.onTapGesture {
if selection == item { return }
if let f = onSelection {
f(item)
} else {
selection = item
}
}
._onButtonGesture { down in
if down {
tapped = item
} else {
tapped = nil
}
} perform: {}
}
.environment(\.editMode, .constant(.active))
}
}
enum NotificationAlert: Identifiable {
case setMode(mode: NotificationsMode)
case error(title: LocalizedStringKey, error: String)
var id: String {
switch self {
case let .setMode(mode): return "enable \(mode.rawValue)"
case let .error(title, error): return "error \(title): \(error)"
}
}
}
struct NotificationsView_Previews: PreviewProvider {
static var previews: some View {
NotificationsView()
}
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
private let serversFont = Font.custom("Menlo", size: 14)

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")!
@@ -17,41 +18,41 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as?
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
let DEFAULT_USE_NOTIFICATIONS = "useNotifications"
let DEFAULT_PENDING_CONNECTIONS = "pendingConnections"
let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
let DEFAULT_CHAT_V3_DB_MIGRATION = "chatV3DBMigration"
let appDefaults: [String: Any] = [
DEFAULT_SHOW_LA_NOTICE: false,
DEFAULT_LA_NOTICE_SHOWN: false,
DEFAULT_PERFORM_LA: false,
DEFAULT_USE_NOTIFICATIONS: false,
DEFAULT_PENDING_CONNECTIONS: true,
DEFAULT_WEBRTC_POLICY_RELAY: true,
DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
DEFAULT_EXPERIMENTAL_CALLS: false
DEFAULT_EXPERIMENTAL_CALLS: false,
DEFAULT_CHAT_V3_DB_MIGRATION: "offer"
]
private var indent: CGFloat = 36
let chatArchiveTimeDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_ARCHIVE_TIME)
struct SettingsView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@AppStorage(DEFAULT_USE_NOTIFICATIONS) private var useNotifications = false
@AppStorage(DEFAULT_PENDING_CONNECTIONS) private var pendingConnections = true
@AppStorage(DEFAULT_EXPERIMENTAL_CALLS) private var enableCalls = false
@State var showNotificationsAlert: Bool = false
@State var whichNotificationsAlert = NotificationAlert.enable
var body: some View {
let user: User = chatModel.currentUser!
return NavigationView {
NavigationView {
List {
Section("You") {
NavigationLink {
@@ -61,23 +62,48 @@ struct SettingsView: View {
ProfilePreview(profileOf: user)
.padding(.leading, -8)
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
UserAddress()
.navigationTitle("Your chat address")
} label: {
settingsRow("qrcode") { Text("Your SimpleX contact address") }
}
.disabled(chatModel.chatRunning != true)
NavigationLink {
DatabaseView(showSettings: $showSettings)
.navigationTitle("Your chat database")
} label: {
settingsRow("internaldrive") {
HStack {
Text("Database export & import")
Spacer()
if chatModel.chatRunning == false {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
}
}
}
}
}
Section("Settings") {
if enableCalls {
NavigationLink {
CallSettings()
.navigationTitle("Your calls")
} label: {
settingsRow("video") { Text("Audio & video calls") }
NavigationLink {
NotificationsView()
.navigationTitle("Notifications")
} label: {
HStack {
notificationsIcon()
Text("Notifications")
}
}
NavigationLink {
CallSettings()
.navigationTitle("Your calls")
} label: {
settingsRow("video") { Text("Audio & video calls") }
}
NavigationLink {
PrivacySettings()
.navigationTitle("Your privacy")
@@ -94,6 +120,7 @@ struct SettingsView: View {
settingsRow("server.rack") { Text("SMP servers") }
}
}
.disabled(chatModel.chatRunning != true)
Section("Help") {
NavigationLink {
@@ -127,6 +154,7 @@ struct SettingsView: View {
Text("Chat with the developers")
}
}
.disabled(chatModel.chatRunning != true)
settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") }
}
@@ -136,6 +164,7 @@ struct SettingsView: View {
} label: {
settingsRow("terminal") { Text("Chat console") }
}
.disabled(chatModel.chatRunning != true)
ZStack(alignment: .leading) {
Image(colorScheme == .dark ? "github_light" : "github")
.resizable()
@@ -144,17 +173,11 @@ struct SettingsView: View {
Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)")
.padding(.leading, indent)
}
NavigationLink {
ExperimentalFeaturesView()
.navigationTitle("Experimental features")
} label: {
settingsRow("gauge") { Text("Experimental features") }
}
// if let token = chatModel.deviceToken {
// HStack {
// notificationsIcon()
// notificationsToggle(token)
// }
// NavigationLink {
// ExperimentalFeaturesView()
// .navigationTitle("Experimental features")
// } label: {
// settingsRow("gauge") { Text("Experimental features") }
// }
Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
}
@@ -174,13 +197,13 @@ struct SettingsView: View {
switch (chatModel.tokenStatus) {
case .new:
icon = "bolt"
color = .primary
color = .secondary
case .registered:
icon = "bolt.fill"
color = .primary
color = .secondary
case .invalid:
icon = "bolt.slash"
color = .primary
color = .secondary
case .confirmed:
icon = "bolt.fill"
color = .yellow
@@ -189,80 +212,20 @@ struct SettingsView: View {
color = .green
case .expired:
icon = "bolt.slash.fill"
color = .primary
color = .secondary
case .none:
icon = "bolt"
color = .secondary
}
return Image(systemName: icon)
.padding(.trailing, 9)
.foregroundColor(color)
}
private func notificationsToggle(_ token: String) -> some View {
Toggle("Check messages", isOn: $useNotifications)
.onChange(of: useNotifications) { enable in
if enable {
showNotificationsAlert = true
whichNotificationsAlert = .enable
} else {
Task {
do {
try await apiDeleteToken(token: token)
chatModel.tokenStatus = .new
}
catch {
DispatchQueue.main.async {
if let cr = error as? ChatResponse {
let err = String(describing: cr)
logger.error("apiDeleteToken error: \(err)")
showNotificationsAlert = true
whichNotificationsAlert = .error("Error deleting token", err)
} else {
logger.error("apiDeleteToken unknown error: \(error.localizedDescription)")
}
}
}
}
}
}
.alert(isPresented: $showNotificationsAlert) {
switch (whichNotificationsAlert) {
case .enable: return enableNotificationsAlert(token)
case let .error(title, err): return Alert(title: Text(title), message: Text(err))
}
}
}
private func enableNotificationsAlert(_ token: String) -> Alert {
Alert(
title: Text("Enable notifications? (BETA)"),
message: Text("The app can receive background notifications every 20 minutes to check the new messages.\n*Please note*: if you confirm, your device token will be sent to SimpleX Chat notifications server."),
primaryButton: .destructive(Text("Confirm")) {
Task {
do {
chatModel.tokenStatus = try await apiRegisterToken(token: token)
} catch {
DispatchQueue.main.async {
useNotifications = false
if let cr = error as? ChatResponse {
let err = String(describing: cr)
logger.error("apiRegisterToken error: \(err)")
showNotificationsAlert = true
whichNotificationsAlert = .error("Error registering token", err)
} else {
logger.error("apiRegisterToken unknown error: \(error.localizedDescription)")
}
}
}
}
}, secondaryButton: .cancel() {
withAnimation() { useNotifications = false }
}
)
}
}
func settingsRow<Content : View>(_ icon: String, content: @escaping () -> Content) -> some View {
func settingsRow<Content : View>(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View {
ZStack(alignment: .leading) {
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary)
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color)
content().padding(.leading, indent)
}
}

View File

@@ -13,61 +13,63 @@ struct UserAddress: View {
@State private var deleteAddressAlert = false
var body: some View {
VStack (alignment: .leading) {
Text("You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.")
.padding(.bottom)
if let userAdress = chatModel.userAddress {
QRCode(uri: userAdress)
HStack {
Button {
showShareSheet(items: [userAdress])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
.padding()
ScrollView {
VStack (alignment: .leading) {
Text("You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.")
.padding(.bottom)
if let userAdress = chatModel.userAddress {
QRCode(uri: userAdress)
HStack {
Button {
showShareSheet(items: [userAdress])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
.padding()
Button(role: .destructive) { deleteAddressAlert = true } label: {
Label("Delete address", systemImage: "trash")
}
.padding()
.alert(isPresented: $deleteAddressAlert) {
Alert(
title: Text("Delete address?"),
message: Text("All your contacts will remain connected"),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
try await apiDeleteUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = nil
Button(role: .destructive) { deleteAddressAlert = true } label: {
Label("Delete address", systemImage: "trash")
}
.padding()
.alert(isPresented: $deleteAddressAlert) {
Alert(
title: Text("Delete address?"),
message: Text("All your contacts will remain connected"),
primaryButton: .destructive(Text("Delete")) {
Task {
do {
try await apiDeleteUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = nil
}
} catch let error {
logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
}
} catch let error {
logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
}
}
}, secondaryButton: .cancel()
)
}
}
.frame(maxWidth: .infinity)
} else {
Button {
Task {
do {
let userAddress = try await apiCreateUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = userAddress
}
} catch let error {
logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)")
}, secondaryButton: .cancel()
)
}
}
} label: { Label("Create address", systemImage: "qrcode") }
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity)
} else {
Button {
Task {
do {
let userAddress = try await apiCreateUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = userAddress
}
} catch let error {
logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)")
}
}
} label: { Label("Create address", systemImage: "qrcode") }
.frame(maxWidth: .infinity)
}
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
struct UserProfile: View {
@EnvironmentObject var chatModel: ChatModel

View File

@@ -1,23 +0,0 @@
//
// dummy.m
// SimpleX
//
// Created by Evgeny Poberezkin on 22/01/2022.
//
#import <Foundation/Foundation.h>
#if defined(__x86_64__) && TARGET_IPHONE_SIMULATOR
#import <dirent.h>
int readdir_r$INODE64(DIR *restrict dirp, struct dirent *restrict entry,
struct dirent **restrict result) {
return readdir_r(dirp, entry, result);
}
DIR *opendir$INODE64(const char *name) {
return opendir(name);
}
#endif

View File

@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "en",
"toolInfo" : {
"toolBuildNumber" : "13E113",
"toolBuildNumber" : "13F100",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "13.3"
"toolVersion" : "13.4.1"
},
"version" : "1.0"
}

View File

@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "ru",
"toolInfo" : {
"toolBuildNumber" : "13E113",
"toolBuildNumber" : "13F100",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "13.3"
"toolVersion" : "13.4.1"
},
"version" : "1.0"
}

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