Compare commits

..

10 Commits

Author SHA1 Message Date
Evgeny Poberezkin
88c57c82d4 file provider works 2022-06-10 22:49:48 +01:00
Evgeny Poberezkin
e01be483da file provider service is working 2022-06-09 17:29:58 +01:00
Evgeny Poberezkin
c7b5d73512 Merge branch 'ios-notifications' into ep/ios-file-provider 2022-06-09 15:20:43 +01:00
Evgeny Poberezkin
360553deeb file provider experiment 2022-06-02 22:44:32 +01:00
Evgeny Poberezkin
b62f2acca7 trying to send service endpoint via mach message - it does not work 2022-06-02 18:24:45 +01:00
Evgeny Poberezkin
af3dcc4a9a file provider does not connect 2022-06-02 15:53:44 +01:00
Evgeny Poberezkin
b0a81252c9 Merge branch 'ios-notifications' into ep/ios-file-provider 2022-06-02 13:20:21 +01:00
Evgeny Poberezkin
e15d4ac6b6 Merge branch 'ios-notifications' into ep/ios-file-provider 2022-06-02 12:41:07 +01:00
Evgeny Poberezkin
17b8101d88 file provider: making service work 2022-06-02 11:06:45 +01:00
Evgeny Poberezkin
1b972bc7cc ios: file provider service 2022-06-01 17:26:15 +01:00
162 changed files with 5256 additions and 7442 deletions

1
.gitignore vendored
View File

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

View File

@@ -39,7 +39,6 @@
- [SimpleX Platform design](#simplex-platform-design)
- [For developers](#for-developers)
- [Roadmap](#roadmap)
- [Help us pay for 3rd party security audit](#help-us-pay-for-3rd-party-security-audit)
- [Disclaimer, License](#disclaimer)
## Why privacy matters
@@ -72,15 +71,13 @@ You can use SimpleX with your own servers and still communicate with people usin
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
2. _Why should I not just use Signal?_ Signal is a centralised platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
2. _Why should I not just use Signal?_ This [post](https://github.com/dessalines/essays/blob/master/why_not_signal.md) shows why Signal cannot be considered a private messenger. Signal is a centralised platform that uses phone numbers to identify its users and their contacts.
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
## News and updates
Selected updates:
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
[Jun 4, 2022. v2.2: the new Privacy and Security settings](./blog/20220604-simplex-chat-new-privacy-security-settings.md)
[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md)
@@ -144,13 +141,11 @@ If you are considering developing with SimpleX platform please get in touch for
- ✅ Haskell chat bot templates.
- ✅ v2.0 - supporting images and files in mobile apps.
- ✅ Manual chat history deletion.
- End-to-end encrypted WebRTC audio and video calls via the mobile apps.
- Privacy preserving instant notifications for iOS using Apple Push Notification service.
- ✅ Chat database export and import
- 🚀 End-to-end encrypted audio and video calls via the mobile apps (enable via Experimental Features).
- 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress).
- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress).
- 🏗 Connecting to messaging servers via Tor (in progress).
- 🏗 Chat groups in mobile apps (in progress).
- Chat database encryption.
- 🏗 Chat database portability and encryption.
- Groups support for mobile apps.
- Disappearing messages, with mutual agreement.
- Web widgets for custom interactivity in the chats.
- SMP protocol improvements:
@@ -163,26 +158,6 @@ If you are considering developing with SimpleX platform please get in touch for
- Media server to optimize sending large files to groups.
- Channels server for large groups and broadcast channels.
## Help us pay for 3rd party security audit
I will get straight to the point: I ask you to support SimpleX Chat with donations.
We are prioritizing users privacy and security - it would be impossible without your support we were lucky to have so far.
We are planning a 3rd party security audit for the app, and it would hugely help us if some part of this $20,000+ expense could be covered with donations.
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
If you are already using SimpleX Chat, or plan to use it in the future when it has more features, please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us.
It is possible to [donate via GitHub](https://github.com/sponsors/simplex-chat), which is commission-free for us, or [via OpenCollective](https://opencollective.com/simplex-chat), that also accepts donations in crypto-currencies, but charges a commission.
Thank you,
Evgeny
SimpleX Chat founder
## Disclaimer
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit.

View File

@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 44
versionName "3.0.1"
versionCode 36
versionName "2.2"
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.im:5349"] },
{ urls: ["turn:turn.simplex.im:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
{ urls: ["stun:stun.simplex.chat:5349"] },
{ urls: ["turn:turn.simplex.chat:5349"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
];
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
return {

View File

@@ -24,11 +24,9 @@ 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) {
@@ -50,16 +48,3 @@ 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,8 +305,7 @@ fun MainPage(
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else {
showAdvertiseLAAlert = true
val stopped = chatModel.chatRunning.value == false
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped)
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) })
else ChatView(chatModel)
}
}

View File

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

View File

@@ -7,7 +7,6 @@ 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
@@ -21,7 +20,6 @@ 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 }
@@ -49,6 +47,7 @@ class SimplexService: Service() {
val text = getString(R.string.simplex_service_notification_text)
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
}
@@ -72,6 +71,7 @@ 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,8 +88,6 @@ 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
@@ -100,7 +98,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)
}
@@ -123,7 +121,7 @@ class SimplexService: Service() {
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_service_icon)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(text)
@@ -215,7 +213,6 @@ 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,8 +23,6 @@ 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>()
@@ -47,8 +45,8 @@ class ChatModel(val controller: ChatController) {
// current WebRTC call
val callManager = CallManager(this)
val callInvitations = mutableStateMapOf<String, RcvCallInvitation>()
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
val callInvitations = mutableStateMapOf<String, CallInvitation>()
val activeCallInvitation = mutableStateOf<CallInvitation?>(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: RcvCallInvitation) {
fun notifyCallInvitation(invitation: CallInvitation) {
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.callType.media == CallMediaType.Video) {
if (invitation.peerMedia == 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,9 +81,6 @@ 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(
@@ -103,15 +100,6 @@ 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"
@@ -125,17 +113,12 @@ 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()
@@ -145,12 +128,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun startChat(user: User) {
Log.d(TAG, "user: $user")
try {
val justStarted = apiStartChat()
val chatStarted = apiStartChat()
apiSetFilesFolder(getAppFilesDirectory(appContext))
chatModel.userAddress.value = apiGetUserAddress()
chatModel.userSMPServers.value = getUserSMPServers()
val chats = apiGetChats()
if (justStarted) {
if (chatStarted) {
chatModel.chats.clear()
chatModel.chats.addAll(chats)
} else {
@@ -159,9 +142,6 @@ 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")
@@ -169,9 +149,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
private fun startReceiver() {
fun startReceiver() {
Log.d(TAG, "ChatController startReceiver")
if (receiverStarted) return
thread(name="receiver") {
GlobalScope.launch { withContext(Dispatchers.IO) { recvMspLoop() } }
}
@@ -197,23 +176,18 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
private suspend fun recvMsg(): CR? {
suspend fun recvMsg(): CR {
return withContext(Dispatchers.IO) {
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
}
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
}
}
private suspend fun recvMspLoop() {
val msg = recvMsg()
if (msg != null) processReceivedMsg(msg)
suspend fun recvMspLoop() {
processReceivedMsg(recvMsg())
recvMspLoop()
}
@@ -241,39 +215,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
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) {
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 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> {
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}")
@@ -308,7 +256,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
private suspend fun getUserSMPServers(): List<String>? {
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}")
@@ -435,7 +383,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return false
}
private suspend fun apiGetUserAddress(): String? {
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
@@ -618,8 +566,10 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
removeFile(appContext, fileName)
}
}
is CR.CallInvitation ->
chatModel.callManager.reportNewIncomingCall(r.callInvitation)
is CR.CallInvitation -> {
val invitation = CallInvitation(r.contact, r.callType.media, r.sharedKey, r.callTs)
chatModel.callManager.reportNewIncomingCall(invitation)
}
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
@@ -887,11 +837,7 @@ 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()
@@ -925,11 +871,7 @@ 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))}"
@@ -964,11 +906,7 @@ 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"
@@ -1010,9 +948,6 @@ 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
@@ -1046,7 +981,6 @@ 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()
@@ -1088,7 +1022,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 callInvitation: RcvCallInvitation): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant): 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()
@@ -1105,7 +1039,6 @@ sealed class CR {
is ActiveUser -> "activeUser"
is ChatStarted -> "chatStarted"
is ChatRunning -> "chatRunning"
is ChatStopped -> "chatStopped"
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is UserSMPServers -> "userSMPServers"
@@ -1165,7 +1098,6 @@ 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)
@@ -1207,7 +1139,7 @@ sealed class CR {
is SndFileRcvCancelled -> json.encodeToString(chatItem)
is SndFileStart -> json.encodeToString(chatItem)
is SndGroupFileCancelled -> json.encodeToString(chatItem)
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
is CallInvitation -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${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,7 +8,6 @@ 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
@@ -18,7 +17,8 @@ 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.*
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.SendMsgView
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
@@ -82,10 +82,6 @@ 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 ->
@@ -105,9 +101,8 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
)
}
val len = terminalItems.count()
if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) {
if (len > 1) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
}

View File

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

View File

@@ -1,8 +1,6 @@
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.*
@@ -37,7 +35,6 @@ 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
@@ -47,8 +44,6 @@ 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")
@@ -84,10 +79,6 @@ 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)
@@ -126,43 +117,21 @@ 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)) {
@@ -205,11 +174,6 @@ 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)
}
}
}
}
}
@@ -230,21 +194,12 @@ 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_audio_off, toggleAudio)
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_video_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) =
@@ -438,7 +393,6 @@ fun PreviewActiveCallOverlayVideo() {
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}
@@ -459,7 +413,6 @@ fun PreviewActiveCallOverlayAudio() {
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}

View File

@@ -112,7 +112,7 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
}
@Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
fun IncomingCallLockScreenAlert(invitation: CallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
@@ -141,7 +141,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
@Composable
fun IncomingCallLockScreenAlertLayout(
invitation: RcvCallInvitation,
invitation: CallInvitation,
callOnLockScreen: CallOnLockScreen?,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
@@ -210,9 +210,9 @@ fun PreviewIncomingCallLockScreenAlert() {
.background(MaterialTheme.colors.background)
.fillMaxSize()) {
IncomingCallLockScreenAlertLayout(
invitation = RcvCallInvitation(
invitation = CallInvitation(
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
peerMedia = CallMediaType.Audio,
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: RcvCallInvitation, chatModel: ChatModel) {
fun IncomingCallAlertView(invitation: CallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
@@ -40,7 +40,7 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
@Composable
fun IncomingCallAlertLayout(
invitation: RcvCallInvitation,
invitation: CallInvitation,
rejectCall: () -> Unit,
ignoreCall: () -> Unit,
acceptCall: () -> Unit
@@ -60,10 +60,10 @@ fun IncomingCallAlertLayout(
}
@Composable
fun IncomingCallInfo(invitation: RcvCallInvitation) {
fun IncomingCallInfo(invitation: CallInvitation) {
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
Row {
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
if (invitation.peerMedia == 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 = RcvCallInvitation(
invitation = CallInvitation(
contact = Contact.sampleData,
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
peerMedia = CallMediaType.Audio,
sharedKey = null,
callTs = Clock.System.now()
),

View File

@@ -18,7 +18,6 @@ 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
) {
@@ -91,12 +90,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 RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(callType.media) {
@Serializable class CallInvitation(val contact: Contact, val peerMedia: CallMediaType, val sharedKey: String?, val callTs: Instant) {
val callTypeText: String get() = generalGetString(when(peerMedia) {
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(callType.media) {
val callTitle: String get() = generalGetString(when(peerMedia) {
CallMediaType.Video -> R.string.incoming_video_call
CallMediaType.Audio -> R.string.incoming_audio_call
})

View File

@@ -45,6 +45,7 @@ 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)
@@ -85,6 +86,7 @@ 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 ->
@@ -139,6 +141,7 @@ fun ChatLayout(
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
useLinkPreviews: Boolean,
enableCalls: Boolean = false,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
@@ -166,7 +169,7 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
topBar = { ChatInfoToolbar(chat, enableCalls, back, info, startCall) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
@@ -180,7 +183,7 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
fun ChatInfoToolbar(chat: Chat, enableCalls: Boolean, 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)
@@ -197,7 +200,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (
) {
val cInfo = chat.chatInfo
toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back)
if (cInfo is ChatInfo.Direct) {
if (cInfo is ChatInfo.Direct && enableCalls) {
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,7 +1,6 @@
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.*
@@ -29,7 +28,6 @@ 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)
@@ -38,35 +36,31 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
when (chat.chatInfo) {
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, stopped) },
chatLinkPreview = { ChatPreviewView(chat) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
showMenu
)
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, stopped) },
chatLinkPreview = { ChatPreviewView(chat) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
showMenu
)
is ChatInfo.ContactRequest ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactRequestView(chat.chatInfo) },
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu,
stopped
showMenu
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) },
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu,
stopped
showMenu
)
}
}
@@ -292,12 +286,17 @@ fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>,
stopped: Boolean
showMenu: MutableState<Boolean>
) {
var modifier = Modifier.fillMaxWidth().height(88.dp)
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) {
Surface(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = click,
onLongClick = { showMenu.value = true }
)
.height(88.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -346,14 +345,12 @@ fun PreviewChatListNavLinkDirect() {
)
),
chatStats = Chat.ChatStats()
),
stopped = false
)
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
showMenu = remember { mutableStateOf(false) }
)
}
}
@@ -381,14 +378,12 @@ fun PreviewChatListNavLinkGroup() {
)
),
chatStats = Chat.ChatStats()
),
stopped = false
)
)
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
showMenu = remember { mutableStateOf(false) }
)
}
}
@@ -408,8 +403,7 @@ fun PreviewChatListNavLinkContactRequest() {
},
click = {},
dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) },
stopped = false
showMenu = remember { mutableStateOf(false) }
)
}
}

View File

@@ -7,7 +7,6 @@ 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.*
@@ -21,8 +20,6 @@ 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
@@ -67,7 +64,7 @@ fun scaffoldController(): ScaffoldController {
}
@Composable
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
val scaffoldCtrl = scaffoldController()
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse()
@@ -85,7 +82,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl, stopped)
ChatListToolbar(scaffoldCtrl)
Divider()
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
@@ -106,7 +103,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
}
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -130,24 +127,13 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) {
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(5.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)
)
}
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
stringResource(R.string.add_contact),
tint = MaterialTheme.colors.primary,
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, stopped: Boolean) {
fun ChatPreviewView(chat: Chat) {
Row {
val cInfo = chat.chatInfo
ChatInfoImage(cInfo, size = 72.dp)
@@ -80,7 +80,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
.background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
.background(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, stopped = false)
ChatPreviewView(Chat.sampleData)
}
}

View File

@@ -1,145 +0,0 @@
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_archive_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

@@ -1,473 +0,0 @@
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(m.chatRunning.value ?: true) }
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, chatLastStart) },
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>, chatLastStart: MutableState<Instant?>) {
withApi {
try {
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
val ts = Clock.System.now()
m.controller.appPrefs.chatLastStart.set(ts)
chatLastStart.value = ts
} 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

@@ -27,10 +27,12 @@ import chat.simplex.app.BuildConfig
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.CIFile
import kotlinx.coroutines.*
import java.io.*
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.*
import kotlin.math.log2
import kotlin.math.pow
fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action)
@@ -243,7 +245,7 @@ fun getLoadedImage(context: Context, file: CIFile?): Bitmap? {
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
val image = BitmapFactory.decodeFileDescriptor(fileDescriptor)
parcelFileDescriptor?.close()
image
} catch (e: Exception) {
@@ -254,39 +256,6 @@ fun getLoadedImage(context: Context, file: CIFile?): Bitmap? {
}
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
}
}
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
fun getFileName(context: Context, uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)

View File

@@ -5,7 +5,6 @@ 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
@@ -24,14 +23,12 @@ 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)
@@ -45,10 +42,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,
@@ -69,10 +66,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),
@@ -97,47 +94,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, disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
ProfilePreview(profile)
}
divider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
divider()
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) })
}
spacer()
SettingsSectionView(stringResource(R.string.settings_section_title_settings)) {
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped)
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) })
divider()
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground)
divider()
PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped)
divider()
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped)
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) })
}
spacer()
SettingsSectionView(stringResource(R.string.settings_section_title_help)) {
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped)
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) })
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, disabled = stopped)
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary)
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, stopped)
ChatConsoleItem(showTerminal)
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()
}
}
@@ -146,49 +143,19 @@ 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,
stopped: Boolean
setRunServiceInBackground: (Boolean) -> Unit
) {
SettingsItemView(disabled = stopped) {
SettingsItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Outlined.Bolt,
@@ -201,8 +168,7 @@ fun SettingsLayout(
Modifier
.padding(end = 24.dp)
.fillMaxWidth()
.weight(1f),
color = if (stopped) HighOrLowlight else Color.Unspecified
.weight(1f)
)
Switch(
checked = runServiceInBackground.value,
@@ -211,8 +177,7 @@ fun SettingsLayout(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 6.dp),
enabled = !stopped
modifier = Modifier.padding(end = 6.dp)
)
}
}
@@ -246,18 +211,15 @@ fun SettingsLayout(
}
}
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) {
SettingsItemView(showTerminal, disabled = stopped) {
@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) {
SettingsItemView(showTerminal) {
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),
color = if (stopped) HighOrLowlight else Color.Unspecified
)
Text(stringResource(R.string.chat_console))
}
}
@@ -279,7 +241,7 @@ fun SettingsLayout(
}
}
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) {
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary) {
ProfileImage(size = size, image = profileOf.image, color = color)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
@@ -287,23 +249,19 @@ 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, disabled: Boolean = false, content: (@Composable () -> Unit)) {
fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) {
val modifier = Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.height(height)
Row(
if (click == null || disabled) modifier else modifier.clickable(onClick = click),
if (click == null) modifier else modifier.clickable(onClick = click),
verticalAlignment = Alignment.CenterVertically
) {
content()
@@ -311,11 +269,11 @@ fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled:
}
@Composable
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
SettingsItemView(click, disabled = disabled) {
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified) {
SettingsItemView(click) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text, color = if (disabled) HighOrLowlight else textColor)
Text(text, color = textColor)
}
}
@@ -341,10 +299,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.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -244,7 +244,6 @@
<!-- 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>
@@ -405,8 +404,6 @@
<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 -->
@@ -440,48 +437,4 @@
<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="chat_archive_section">АРХИВ ЧАТА</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,7 +250,6 @@
<!-- 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>
@@ -407,8 +406,6 @@
<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 -->
@@ -442,48 +439,4 @@
<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="chat_archive_section">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,7 +8,8 @@
import Foundation
import UIKit
import SimpleXChat
import SimpleXChatSDK
import SimpleXAppShared
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -21,10 +22,16 @@ class AppDelegate: NSObject, UIApplicationDelegate {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
let m = ChatModel.shared
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
m.deviceToken = deviceToken
if m.savedToken != nil {
registerToken(token: deviceToken)
m.deviceToken = token
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))")
}
}
}
}
@@ -36,19 +43,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],
m.notificationMode != .off {
UserDefaults.standard.bool(forKey: "useNotifications") {
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, nonce: nonce, code: verification)
try await apiVerifyToken(token: token, code: verification, nonce: nonce)
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
@@ -61,12 +68,15 @@ 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")
if appStateGroupDefault.get().inactive {
receiveMessages(completionHandler)
} else {
completionHandler(.noData)
}
// 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)
} else {
completionHandler(.noData)
}
@@ -75,11 +85,6 @@ 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,45 +22,39 @@ struct ContentView: View {
ZStack {
if prefPerformLA && userAuthorized != true {
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
} else if !chatModel.v3DBMigration.startChat {
MigrateToAppGroupView()
} else if let step = chatModel.onboardingStage {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
} else {
OnboardingView(onboarding: step)
} 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)
}
}
}
}
.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
@@ -81,7 +75,7 @@ struct ContentView: View {
case .success:
userAuthorized = true
case .failed:
break
AlertManager.shared.showAlert(laFailedAlert())
case .unavailable:
userAuthorized = true
prefPerformLA = false

View File

@@ -8,7 +8,6 @@
import Foundation
import BackgroundTasks
import SimpleXChat
private let receiveTaskId = "chat.simplex.app.receive"
@@ -17,14 +16,11 @@ 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")
@@ -47,16 +43,11 @@ class BGManager {
private func handleRefresh(_ task: BGAppRefreshTask) {
logger.debug("BGManager.handleRefresh")
schedule()
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")
let completeRefresh = completionHandler {
task.setTaskCompleted(success: true)
}
task.expirationHandler = { completeRefresh("expirationHandler") }
receiveMessages(completeRefresh)
}
func completionHandler(_ complete: @escaping () -> Void) -> ((String) -> Void) {
@@ -68,8 +59,6 @@ class BGManager {
self.chatReceiver = nil
self.bgTimer?.invalidate()
self.bgTimer = nil
self.timerCount = 0
suspendBgRefresh()
complete()
}
}
@@ -81,29 +70,21 @@ class BGManager {
return
}
self.completed = false
DispatchQueue.main.async {
do {
try initializeChat(start: true)
} catch let error {
fatalError("Failed to start or load chats: \(responseError(error))")
}
Task {
await initializeChat()
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,14 +10,11 @@ import Foundation
import Combine
import SwiftUI
import WebKit
import SimpleXChat
import SimpleXChatSDK
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
@@ -29,14 +26,10 @@ final class ChatModel: ObservableObject {
@Published var userAddress: String?
@Published var userSMPServers: [String]?
@Published var appOpenUrl: URL?
@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()
@Published var deviceToken: String?
@Published var tokenStatus = NtfTknStatus.new
// current WebRTC call
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@Published var callInvitations: Dictionary<ChatId, CallInvitation> = [:]
@Published var activeCall: Call?
@Published var callCommand: WCallCommand?
@Published var showCallView = false
@@ -58,9 +51,9 @@ final class ChatModel: ObservableObject {
chats.firstIndex(where: { $0.id == id })
}
func addChat(_ chat: Chat, at position: Int = 0) {
func addChat(_ chat: Chat) {
withAnimation {
chats.insert(chat, at: position)
chats.insert(chat, at: 0)
}
}
@@ -94,36 +87,13 @@ 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 i in 0..<newChats.count {
let c = newChats[i]
if let j = getChatIndex(c.id) {
let chat = chats[j]
chat.chatInfo = c.chatInfo
chat.chatItems = c.chatItems
chat.chatStats = c.chatStats
if i != j {
if chatId != c.chatInfo.id {
popChat_(j, to: i)
} else if i == 0 {
chatToTop = c.chatInfo.id
}
}
} else {
addChat(Chat(c), at: i)
}
}
}
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update previews
if let i = getChatIndex(cInfo.id) {
@@ -256,9 +226,9 @@ final class ChatModel: ObservableObject {
}
}
private func popChat_(_ i: Int, to position: Int = 0) {
private func popChat_(_ i: Int) {
let chat = chats.remove(at: i)
chats.insert(chat, at: position)
chats.insert(chat, at: 0)
}
func removeChat(_ id: String) {
@@ -273,7 +243,6 @@ 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
@@ -331,6 +300,4 @@ final class Chat: ObservableObject, Identifiable {
}
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
}

View File

@@ -9,7 +9,8 @@
import Foundation
import UserNotifications
import UIKit
import SimpleXChat
import SimpleXChatSDK
import SimpleXAppShared
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
@@ -136,11 +137,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("Incoming call", comment: "notification")
),
// TODO remove
UNNotificationCategory(
identifier: ntfCategoryConnectionEvent,
identifier: ntfCategoryCheckingMessages,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
hiddenPreviewsBodyPlaceholder: NSLocalizedString("Checking new messages...", comment: "notification")
)
])
}
@@ -183,11 +185,21 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
addNotification(createMessageReceivedNtf(cInfo, cItem))
}
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
func notifyCallInvitation(_ invitation: CallInvitation) {
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

@@ -1,41 +0,0 @@
//
// 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

@@ -11,9 +11,8 @@ import UIKit
import Dispatch
import BackgroundTasks
import SwiftUI
import SimpleXChat
private var chatController: chat_ctrl?
import SimpleXChatSDK
import SimpleXAppShared
enum TerminalItem: Identifiable {
case cmd(Date, ChatCommand)
@@ -47,7 +46,7 @@ enum TerminalItem: Identifiable {
}
}
func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
private func beginBGTask(_ handler: (() -> Void)? = nil) -> (@Sendable () -> Void) {
var id: UIBackgroundTaskIdentifier!
var running = true
let endTask = {
@@ -72,10 +71,10 @@ func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
let msgDelay: Double = 7.5
let maxTaskDuration: Double = 15
private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
private func withBGTask(bgDelay: Double? = nil, f: @escaping () async -> ChatResponse) async -> ChatResponse {
let endTask = beginBGTask()
DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask)
let r = f()
let r = await f()
if let d = bgDelay {
DispatchQueue.global().asyncAfter(deadline: .now() + d, execute: endTask)
} else {
@@ -84,41 +83,32 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
return r
}
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse {
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) async -> ChatResponse {
logger.debug("chatSendCmd \(cmd.cmdType)")
let resp = bgTask
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) }
let resp = await bgTask
? withBGTask(bgDelay: bgDelay) { await sendSimpleXCmd(cmd) }
: sendSimpleXCmd(cmd)
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
DispatchQueue.main.async {
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
ChatModel.shared.terminalItems.append(.resp(.now, resp))
if case .apiParseMarkdown = cmd {} else {
DispatchQueue.main.async {
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
ChatModel.shared.terminalItems.append(.resp(.now, resp))
}
}
return resp
}
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) async -> ChatResponse {
await withCheckedContinuation { cont in
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay))
func chatRecvMsg() async -> ChatResponse {
await withBGTask(bgDelay: msgDelay) {
await recvSimpleXMsg()
}
}
func chatRecvMsg() async -> ChatResponse? {
await withCheckedContinuation { cont in
_ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
let resp = recvSimpleXMsg()
cont.resume(returning: resp)
return resp
}
}
}
func apiGetActiveUser() throws -> User? {
let _ = getChatCtrl()
let r = chatSendCmdSync(.showActiveUser)
func apiGetActiveUser() async throws -> User? {
let r = await chatSendCmd(.showActiveUser)
switch r {
case let .activeUser(user): return user
case .chatCmdError(.error(.noActiveUser)): return nil
@@ -126,14 +116,14 @@ func apiGetActiveUser() throws -> User? {
}
}
func apiCreateActiveUser(_ p: Profile) throws -> User {
let r = chatSendCmdSync(.createActiveUser(profile: p))
func apiCreateActiveUser(_ p: Profile) async throws -> User {
let r = await chatSendCmd(.createActiveUser(profile: p))
if case let .activeUser(user) = r { return user }
throw r
}
func apiStartChat() throws -> Bool {
let r = chatSendCmdSync(.startChat(subscribe: true))
func apiStartChat() async throws -> Bool {
let r = await chatSendCmd(.startChat)
switch r {
case .chatStarted: return true
case .chatRunning: return false
@@ -141,68 +131,24 @@ 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))
func apiSetFilesFolder(filesFolder: String) async throws {
let r = await chatSendCmd(.setFilesFolder(filesFolder: filesFolder))
if case .cmdOk = r { return }
throw r
}
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 }
func apiGetChats() async throws -> [Chat] {
let r = await chatSendCmd(.apiGetChats)
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
throw r
}
func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id))
func apiGetChat(type: ChatType, id: Int64) async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id))
if case let .apiChat(chat) = r { return Chat.init(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)
@@ -238,50 +184,26 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM
throw r
}
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))
func apiRegisterToken(token: String) async throws -> NtfTknStatus {
let r = await chatSendCmd(.apiRegisterToken(token: token))
if case let .ntfTokenStatus(status) = r { return status }
throw r
}
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 apiVerifyToken(token: String, code: String, nonce: String) async throws {
try await sendCommandOkResp(.apiVerifyToken(token: token, code: code, nonce: nonce))
}
func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws {
try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code))
func apiIntervalNofication(token: String, interval: Int) async throws {
try await sendCommandOkResp(.apiIntervalNofication(token: token, interval: interval))
}
func apiDeleteToken(token: DeviceToken) async throws {
func apiDeleteToken(token: String) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
func getUserSMPServers() throws -> [String] {
let r = chatSendCmdSync(.getUserSMPServers)
func getUserSMPServers() async throws -> [String] {
let r = await chatSendCmd(.getUserSMPServers)
if case let .userSMPServers(smpServers) = r { return smpServers }
throw r
}
@@ -290,8 +212,8 @@ func setUserSMPServers(smpServers: [String]) async throws {
try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers))
}
func apiAddContact() throws -> String {
let r = chatSendCmdSync(.addContact, bgTask: false)
func apiAddContact() async throws -> String {
let r = await chatSendCmd(.addContact, bgTask: false)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
throw r
}
@@ -378,6 +300,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
}
}
func apiParseMarkdown(text: String) async throws -> [FormattedText]? {
let r = await sendSimpleXCmd(.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 }
@@ -390,8 +318,8 @@ func apiDeleteUserAddress() async throws {
throw r
}
func apiGetUserAddress() throws -> String? {
let r = chatSendCmdSync(.showMyAddress)
func apiGetUserAddress() async throws -> String? {
let r = await sendSimpleXCmd(.showMyAddress)
switch r {
case let .userContactLink(connReq):
return connReq
@@ -428,7 +356,7 @@ func receiveFile(fileId: Int64) async {
func apiReceiveFile(fileId: Int64) async throws -> AChatItem {
let r = await chatSendCmd(.receiveFile(fileId: fileId))
if case let .rcvFileAccepted(chatItem) = r { return chatItem }
if case .rcvFileAccepted(let chatItem) = r { return chatItem }
throw r
}
@@ -479,12 +407,6 @@ 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))
@@ -520,49 +442,51 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
throw r
}
func initializeChat(start: Bool) throws {
func initializeChat() async {
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 {
m.chatRunning = false
let user = try await apiGetActiveUser()
await MainActor.run {
m.currentUser = user
if user == nil {
m.onboardingStage = .step1_SimpleXInfo
}
}
if user != nil {
await startChat()
}
} catch {
fatalError("Failed to initialize chat controller or database: \(responseError(error))")
fatalError("Failed to initialize chat controller or database: \(error)")
}
}
func startChat() throws {
func startChat() async {
logger.debug("startChat")
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
do {
// TODO set file folder once, before chat is started
let justStarted = try await apiStartChat()
if justStarted {
try await apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
let userAddress = try await apiGetUserAddress()
let userSMPServers = try await getUserSMPServers()
let chats = try await apiGetChats()
DispatchQueue.main.async {
let m = ChatModel.shared
m.userAddress = userAddress
m.userSMPServers = userSMPServers
m.chats = chats
withAnimation {
m.onboardingStage = m.chats.isEmpty
? .step3_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 {
@@ -583,13 +507,12 @@ class ChatReceiver {
}
func receiveMsgLoop() async {
// TODO use function that has timeout
if let msg = await chatRecvMsg() {
self._lastMsgTime = .now
await processReceivedMsg(msg)
}
let msg = await chatRecvMsg()
self._lastMsgTime = .now
await processReceivedMsg(msg)
if self.receiveMessages {
_ = try? await Task.sleep(nanoseconds: 7_500_000)
do { try await Task.sleep(nanoseconds: 7_500_000) }
catch { logger.error("receiveMsgLoop: Task.sleep error: \(error.localizedDescription)") }
await receiveMsgLoop()
}
}
@@ -604,7 +527,7 @@ class ChatReceiver {
func processReceivedMsg(_ res: ChatResponse) async {
let m = ChatModel.shared
await MainActor.run {
DispatchQueue.main.async {
m.terminalItems.append(.resp(.now, res))
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
@@ -704,9 +627,19 @@ func processReceivedMsg(_ res: ChatResponse) async {
let fileName = cItem.file?.filePath {
removeFile(fileName)
}
case let .callInvitation(invitation):
m.callInvitations[invitation.contact.id] = invitation
activateCall(invitation)
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")
}
}
// This will be called from notification service extension
// CXProvider.reportNewIncomingVoIPPushPayload([
@@ -746,8 +679,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.callCommand = .end
// CallController.shared.reportCallRemoteEnded(call: call)
}
case .chatSuspended:
chatSuspended()
default:
logger.debug("unsupported event: \(res.responseType)")
}
@@ -790,27 +721,6 @@ 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

@@ -1,79 +0,0 @@
//
// 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

@@ -7,7 +7,7 @@
import SwiftUI
import OSLog
import SimpleXChat
import SimpleXAppShared
let logger = Logger()
@@ -23,10 +23,8 @@ struct SimpleXApp: App {
@State private var enteredBackground: Double? = nil
init() {
hs_init(0, nil)
// hs_init(0, nil)
UserDefaults.standard.register(defaults: appDefaults)
setGroupDefaults()
setDbContainer()
BGManager.shared.register()
NtfManager.shared.registerCategories()
}
@@ -39,35 +37,25 @@ struct SimpleXApp: App {
logger.debug("ContentView.onOpenURL: \(url)")
chatModel.appOpenUrl = url
}
.onAppear() {
do {
chatModel.v3DBMigration = v3DBMigrationDefault.get()
try initializeChat(start: chatModel.v3DBMigration.startChat)
} catch let error {
fatalError("Failed to start or load chats: \(responseError(error))")
}
.onAppear {
Task { await initializeChat() }
}
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase \(String(describing: scenePhase))")
// let res = machMessenger.sendMessageWithReply(NSE_MACH_PORT, msg: "App scenePhase changed to \(String(describing: scenePhase))")
// logger.debug("MachMessenger \(String(describing: res), privacy: .public)")
setAppState(phase)
switch (phase) {
case .background:
suspendChat()
BGManager.shared.schedule()
if userAuthorized == true {
enteredBackground = ProcessInfo.processInfo.systemUptime
}
doAuthenticate = false
// machMessenger.stop()
case .active:
if chatModel.chatRunning == true {
ChatReceiver.shared.start()
}
let appState = appStateGroupDefault.get()
activateChat()
if appState.inactive && chatModel.chatRunning == true {
updateChats()
updateCallInvitations()
}
doAuthenticate = authenticationExpired()
// machMessenger.start()
default:
break
}
@@ -75,30 +63,6 @@ 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
@@ -106,31 +70,4 @@ 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,7 +8,7 @@
import SwiftUI
import WebKit
import SimpleXChat
import SimpleXChatSDK
struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel

View File

@@ -9,7 +9,7 @@
import Foundation
//import CallKit
import AVFoundation
import SimpleXChat
import SimpleXChatSDK
//class CallController: NSObject, CXProviderDelegate, ObservableObject {
class CallController: NSObject, ObservableObject {
@@ -18,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: RcvCallInvitation?
@Published var activeCallInvitation: CallInvitation?
// PKPushRegistry will be used from notification service extension
// let registry = PKPushRegistry(queue: nil)
@@ -120,8 +120,9 @@ class CallController: NSObject, ObservableObject {
// }
// }
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
func reportNewIncomingCall(invitation: CallInvitation, 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 +142,7 @@ class CallController: NSObject, ObservableObject {
// }
// }
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
func reportCallRemoteEnded(invitation: CallInvitation) {
// 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 +172,7 @@ class CallController: NSObject, ObservableObject {
}
}
func answerCall(invitation: RcvCallInvitation) {
func answerCall(invitation: CallInvitation) {
callManager.answerIncomingCall(invitation: invitation)
if invitation.contact.id == self.activeCallInvitation?.contact.id {
self.activeCallInvitation = nil
@@ -192,7 +193,7 @@ class CallController: NSObject, ObservableObject {
// }
}
func endCall(invitation: RcvCallInvitation) {
func endCall(invitation: CallInvitation) {
callManager.endCall(invitation: invitation) {
if invitation.contact.id == self.activeCallInvitation?.contact.id {
DispatchQueue.main.async {

View File

@@ -7,7 +7,7 @@
//
import Foundation
import SimpleXChat
import SimpleXChatSDK
class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
@@ -34,7 +34,7 @@ class CallManager {
return false
}
func answerIncomingCall(invitation: RcvCallInvitation) {
func answerIncomingCall(invitation: CallInvitation) {
let m = ChatModel.shared
m.callInvitations.removeValue(forKey: invitation.contact.id)
m.activeCall = Call(
@@ -42,13 +42,13 @@ class CallManager {
contact: invitation.contact,
callkitUUID: invitation.callkitUUID,
callState: .invitationAccepted,
localMedia: invitation.callType.media,
localMedia: invitation.peerMedia,
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.callType.media, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay)
m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true, relay: useRelay)
}
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
@@ -86,7 +86,7 @@ class CallManager {
}
}
func endCall(invitation: RcvCallInvitation, completed: @escaping () -> Void) {
func endCall(invitation: CallInvitation, completed: @escaping () -> Void) {
ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id)
Task {
do {
@@ -98,7 +98,7 @@ class CallManager {
}
}
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
private func getCallInvitation(_ callUUID: UUID) -> CallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
return invitation
}

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
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,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel

View File

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

View File

@@ -7,7 +7,8 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
import SimpleXAppShared
struct CIFileView: View {
@Environment(\.colorScheme) var colorScheme

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
import SimpleXAppShared
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,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
struct IntegrityErrorItemView: View {
var chatItem: ChatItem

View File

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

View File

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

View File

@@ -7,13 +7,15 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
import SimpleXAppShared
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()
@@ -59,7 +61,7 @@ struct ChatView: View {
}
markAllRead()
}
.onChange(of: chatModel.chatItems.last?.id) { _ in
.onChange(of: chatModel.chatItems.count) { _ in
scrollToBottom(proxy)
}
.onChange(of: keyboardVisible) { _ in
@@ -107,7 +109,7 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if case let .direct(contact) = cInfo {
if enableCalls, case let .direct(contact) = cInfo {
HStack {
callButton(contact, .audio, imageName: "phone")
callButton(contact, .video, imageName: "video")
@@ -224,7 +226,7 @@ struct ChatView: View {
}
func markAllRead() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
if chatModel.chatId == chat.id {
Task { await markChatRead(chat) }
}

View File

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

View File

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

View File

@@ -7,7 +7,8 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
import SimpleXAppShared
enum ComposePreview {
case noPreview
@@ -164,7 +165,7 @@ struct ComposeView: View {
.onChange(of: composeState.message) { _ in
if composeState.linkPreviewAllowed() {
if composeState.message.count > 0 {
showLinkPreview(composeState.message)
Task { await showLinkPreview(composeState.message) }
} else {
resetLinkPreview()
}
@@ -210,8 +211,9 @@ struct ComposeView: View {
allowedContentTypes: [.data],
allowsMultipleSelection: false
) { result in
if case let .success(files) = result, let fileURL = files.first {
if case .success = result {
do {
let fileURL: URL = try result.get().first!
var fileSize: Int? = nil
if fileURL.startAccessingSecurityScopedResource() {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
@@ -305,7 +307,7 @@ struct ComposeView: View {
case .noPreview:
mc = .text(composeState.message)
case .linkPreview:
mc = checkLinkPreview()
mc = await checkLinkPreview()
case let .imagePreview(imagePreview: image):
if let uiImage = chosenImage,
let savedFile = saveImage(uiImage) {
@@ -356,12 +358,12 @@ struct ComposeView: View {
chosenFile = nil
}
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
private func updateMsgContent(_ msgContent: MsgContent) async -> MsgContent {
switch msgContent {
case .text:
return checkLinkPreview()
return await checkLinkPreview()
case .link:
return checkLinkPreview()
return await checkLinkPreview()
case .image(_, let image):
return .image(text: composeState.message, image: image)
case .file:
@@ -371,9 +373,9 @@ struct ComposeView: View {
}
}
private func showLinkPreview(_ s: String) {
private func showLinkPreview(_ s: String) async {
prevLinkUrl = linkUrl
linkUrl = parseMessage(s)
linkUrl = await parseMessage(s)
if let url = linkUrl {
if url != composeState.linkPreview()?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
@@ -390,13 +392,18 @@ struct ComposeView: View {
}
}
private func parseMessage(_ msg: String) -> URL? {
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 parseMessage(_ msg: String) async -> URL? {
do {
let parsedMsg = try await 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
}
}
private func isSimplexLink(_ link: String) -> Bool {
@@ -431,10 +438,10 @@ struct ComposeView: View {
cancelledLinks = []
}
private func checkLinkPreview() -> MsgContent {
private func checkLinkPreview() async -> MsgContent {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
if let url = parseMessage(composeState.message),
if let url = await parseMessage(composeState.message),
let linkPreview = linkPreview,
url == linkPreview.uri {
return .link(text: composeState.message, preview: linkPreview)

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@@ -20,10 +20,9 @@ struct ChatListView: View {
var body: some View {
let v = NavigationView {
List {
ForEach(filteredChats(), id: \.viewId) { chat in
ForEach(filteredChats()) { chat in
ChatListNavLink(chat: chat, showChatInfo: $showChatInfo)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true)
}
}
.onChange(of: chatModel.chatId) { _ in
@@ -34,7 +33,7 @@ struct ChatListView: View {
}
.onChange(of: chatModel.chats.isEmpty) { empty in
if !empty { return }
withAnimation { chatModel.onboardingStage = .step4_MakeConnection }
withAnimation { chatModel.onboardingStage = .step3_MakeConnection }
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onAppear() { connectViaUrl() }
@@ -47,11 +46,7 @@ struct ChatListView: View {
SettingsButton()
}
ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning {
case .some(true): NewChatButton()
case .some(false): chatStoppedIcon()
case .none: EmptyView()
}
NewChatButton()
}
}
}
@@ -79,17 +74,6 @@ 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,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
struct ChatPreviewView: View {
@ObservedObject var chat: Chat

View File

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

View File

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

View File

@@ -1,65 +0,0 @@
//
// 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

@@ -1,332 +0,0 @@
//
// 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

@@ -1,251 +0,0 @@
//
// 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,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
struct ChatInfoImage: View {
@ObservedObject var chat: Chat

View File

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

View File

@@ -12,27 +12,25 @@ import CoreImage.CIFilterBuiltins
struct AddContactView: View {
var connReqInvitation: String
var body: some View {
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)
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")
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
.frame(maxWidth: .infinity, alignment: .center)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}

View File

@@ -7,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
enum NewChatAction: Identifiable {
case createLink
@@ -27,7 +27,7 @@ struct NewChatButton: View {
Image(systemName: "person.crop.circle.badge.plus")
}
.confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
Button("Create link / QR code") { addContactAction() }
Button("Create link / QR code") { Task { await addContactAction() } }
Button("Paste received link") { actionSheet = .pasteLink }
Button("Scan QR code") { actionSheet = .scanQRCode }
}
@@ -40,9 +40,9 @@ struct NewChatButton: View {
}
}
func addContactAction() {
func addContactAction() async {
do {
connReq = try apiAddContact()
connReq = try await apiAddContact()
actionSheet = .createLink
} catch {
DispatchQueue.global().async {

View File

@@ -7,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
struct CreateProfile: View {
@EnvironmentObject var m: ChatModel
@@ -43,7 +43,7 @@ struct CreateProfile: View {
.focused($focusFullName)
.submitLabel(.go)
.onSubmit {
if canCreateProfile() { createProfile() }
if canCreateProfile() { Task { await createProfile() } }
else { focusFullName = true }
}
@@ -64,7 +64,7 @@ struct CreateProfile: View {
HStack {
Button {
createProfile()
Task { await createProfile() }
} label: {
Text("Create")
Image(systemName: "greaterthan")
@@ -87,19 +87,21 @@ struct CreateProfile: View {
.padding(.bottom)
}
func createProfile() {
func createProfile() async {
hideKeyboard()
let profile = Profile(
displayName: displayName,
fullName: fullName
)
do {
m.currentUser = try apiCreateActiveUser(profile)
try startChat()
withAnimation { m.onboardingStage = .step3_SetNotificationsMode }
let user = try await apiCreateActiveUser(profile)
await MainActor.run { m.currentUser = user }
await startChat()
DispatchQueue.main.async {
withAnimation { m.onboardingStage = .step3_MakeConnection }
}
} catch {
fatalError("Failed to create user or start chat: \(responseError(error))")
fatalError("Failed to create user: \(error)")
}
}

View File

@@ -7,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
struct MakeConnection: View {
@EnvironmentObject var m: ChatModel
@@ -16,14 +16,7 @@ struct MakeConnection: View {
var body: some View {
VStack(alignment: .leading) {
HStack {
SettingsButton()
if m.chatRunning == false {
Spacer()
chatStoppedIcon()
}
}
.padding(.bottom, 1)
SettingsButton().padding(.bottom, 1)
if let user = m.currentUser {
Text("Welcome \(user.displayName)!")
@@ -45,7 +38,7 @@ struct MakeConnection: View {
icon: "link.badge.plus",
title: "Create 1-time link / QR code",
text: "It's secure to share - only one contact can use it."
) { addContactAction() }
) { Task { await addContactAction() } }
actionRow(
icon: "doc.plaintext",
@@ -73,7 +66,6 @@ struct MakeConnection: View {
}
}
}
.disabled(m.chatRunning != true)
}
Spacer()
@@ -110,9 +102,9 @@ struct MakeConnection: View {
}
}
private func addContactAction() {
private func addContactAction() async {
do {
connReq = try apiAddContact()
connReq = try await apiAddContact()
actionSheet = .createLink
} catch {
DispatchQueue.global().async {

View File

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

View File

@@ -1,112 +0,0 @@
//
// 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: .step4_MakeConnection)
actionButton("Make a private connection", onboarding: .step3_MakeConnection)
}
}

View File

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

View File

@@ -21,9 +21,10 @@ struct CallSettings: View {
Section("Limitations") {
VStack(alignment: .leading, spacing: 8) {
textListItem("1.", "Do NOT use SimpleX for emergency calls.")
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.")
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.")
}
.font(.callout)
.padding(.vertical, 8)

View File

@@ -1,222 +0,0 @@
//
// 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,7 +7,6 @@
//
import SwiftUI
import SimpleXChat
struct PrivacySettings: View {
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@@ -22,7 +21,6 @@ struct PrivacySettings: View {
Section("Chats") {
settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) { privacyAcceptImagesGroupDefault.set($0) }
}
settingsRow("network") {
Toggle("Send link previews", isOn: $useLinkPreviews)

View File

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

View File

@@ -7,7 +7,7 @@
//
import SwiftUI
import SimpleXChat
import SimpleXChatSDK
let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")!
@@ -18,45 +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_CHAT_V3_DB_MIGRATION: "offer"
DEFAULT_EXPERIMENTAL_CALLS: false
]
private var indent: CGFloat = 36
let chatArchiveTimeDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_ARCHIVE_TIME)
func setGroupDefaults() {
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
}
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!
NavigationView {
return NavigationView {
List {
Section("You") {
NavigationLink {
@@ -66,48 +62,23 @@ 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") {
NavigationLink {
NotificationsView()
.navigationTitle("Notifications")
} label: {
HStack {
notificationsIcon()
Text("Notifications")
if enableCalls {
NavigationLink {
CallSettings()
.navigationTitle("Your calls")
} label: {
settingsRow("video") { Text("Audio & video calls") }
}
}
NavigationLink {
CallSettings()
.navigationTitle("Your calls")
} label: {
settingsRow("video") { Text("Audio & video calls") }
}
NavigationLink {
PrivacySettings()
.navigationTitle("Your privacy")
@@ -124,7 +95,6 @@ struct SettingsView: View {
settingsRow("server.rack") { Text("SMP servers") }
}
}
.disabled(chatModel.chatRunning != true)
Section("Help") {
NavigationLink {
@@ -158,7 +128,6 @@ struct SettingsView: View {
Text("Chat with the developers")
}
}
.disabled(chatModel.chatRunning != true)
settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") }
}
@@ -168,7 +137,6 @@ struct SettingsView: View {
} label: {
settingsRow("terminal") { Text("Chat console") }
}
.disabled(chatModel.chatRunning != true)
ZStack(alignment: .leading) {
Image(colorScheme == .dark ? "github_light" : "github")
.resizable()
@@ -177,12 +145,18 @@ 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") }
// }
NavigationLink {
ExperimentalFeaturesView()
.navigationTitle("Experimental features")
} label: {
settingsRow("gauge") { Text("Experimental features") }
}
if let token = chatModel.deviceToken {
HStack {
notificationsIcon()
notificationsToggle(token)
}
}
Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
}
}
@@ -201,13 +175,13 @@ struct SettingsView: View {
switch (chatModel.tokenStatus) {
case .new:
icon = "bolt"
color = .secondary
color = .primary
case .registered:
icon = "bolt.fill"
color = .secondary
color = .primary
case .invalid:
icon = "bolt.slash"
color = .secondary
color = .primary
case .confirmed:
icon = "bolt.fill"
color = .yellow
@@ -216,20 +190,80 @@ struct SettingsView: View {
color = .green
case .expired:
icon = "bolt.slash.fill"
color = .secondary
case .none:
icon = "bolt"
color = .secondary
color = .primary
}
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, color: Color = .secondary, content: @escaping () -> Content) -> some View {
func settingsRow<Content : View>(_ icon: String, content: @escaping () -> Content) -> some View {
ZStack(alignment: .leading) {
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color)
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary)
content().padding(.leading, indent)
}
}

View File

@@ -13,63 +13,61 @@ struct UserAddress: View {
@State private var deleteAddressAlert = false
var body: some View {
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()
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
}
} catch let error {
logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
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)")
}
}, secondaryButton: .cancel()
)
}
}, 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)")
}
}
.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)
}
} label: { Label("Create address", systemImage: "qrcode") }
.frame(maxWidth: .infinity)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}

View File

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

View File

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

View File

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

View File

@@ -8,93 +8,74 @@
import UserNotifications
import OSLog
import SimpleXChat
import FileProvider
import SimpleXChatSDK
import SimpleXAppShared
import SimpleXServiceProtocol
import Foundation
let logger = Logger()
let suspendingDelay: UInt64 = 2_000_000_000
let machMessenger = MachMessenger(NSE_MACH_PORT, callback: receivedAppMachMessage)
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNNotificationContent?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
logger.debug("NotificationService.didReceive")
bestAttemptContent = request.content
self.contentHandler = contentHandler
let appState = appStateGroupDefault.get()
switch appState {
case .suspended:
logger.debug("NotificationService: app is suspended")
receiveNtfMessages(request, contentHandler)
case .suspending:
logger.debug("NotificationService: app is suspending")
Task {
var state = appState
for _ in 1...5 {
_ = try await Task.sleep(nanoseconds: suspendingDelay)
state = appStateGroupDefault.get()
if state == .suspended || state != .suspending { break }
}
logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)")
if state.inactive {
receiveNtfMessages(request, contentHandler)
} else {
contentHandler(request.content)
}
}
default:
logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)")
contentHandler(request.content)
}
}
func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) {
logger.debug("NotificationService: receiveNtfMessages")
if case .documents = dbContainerGroupDefault.get() {
machMessenger.start()
let res = machMessenger.sendMessageWithReply(APP_MACH_PORT, msg: "starting NSE didReceive")
logger.debug("MachMessenger \(String(describing: res), privacy: .public)")
if getAppState() != .background {
contentHandler(request.content)
machMessenger.stop()
return
}
let userInfo = request.content.userInfo
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
let nonce = ntfData["nonce"] as? String,
let encNtfInfo = ntfData["message"] as? String,
let _ = startChat() {
logger.debug("NotificationService: receiveNtfMessages: chat is started")
if let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)")
if let connEntity = ntfMsgInfo.connEntity {
bestAttemptContent = createConnectionEventNtf(connEntity)
}
if let content = receiveMessageForNotification() {
logger.debug("NotificationService: receiveMessageForNotification: has message")
contentHandler(content)
} else if let content = bestAttemptContent {
logger.debug("NotificationService: receiveMessageForNotification: no message")
contentHandler(content)
}
logger.debug("NotificationService: app is in the background")
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
Task {
if let _ = await startChat() {
let content = await receiveMessages()
contentHandler (content)
machMessenger.stop()
return
}
if let bestAttemptContent = bestAttemptContent {
// Modify the notification content here...
bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
contentHandler(bestAttemptContent)
}
machMessenger.stop()
}
}
override func serviceExtensionTimeWillExpire() {
logger.debug("NotificationService.serviceExtensionTimeWillExpire")
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = self.contentHandler, let content = bestAttemptContent {
contentHandler(content)
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}
func startChat() -> User? {
hs_init(0, nil)
if let user = apiGetActiveUser() {
func receivedAppMachMessage(msgId: Int32, msg: String) -> String? {
logger.debug("MachMessenger: receivedAppMachMessage \"\(msg)\" from App, replying")
return "reply from NSE to: \(msg)"
}
func startChat() async -> User? {
// hs_init(0, nil)
if let user = await apiGetActiveUser() {
logger.debug("active user \(String(describing: user))")
do {
try apiStartChat()
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
chatLastStartGroupDefault.set(Date.now)
try await apiStartChat()
try await apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
return user
} catch {
logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)")
@@ -105,46 +86,48 @@ func startChat() -> User? {
return nil
}
func receiveMessageForNotification() -> UNNotificationContent? {
func receiveMessages() async -> UNNotificationContent {
logger.debug("NotificationService receiveMessages started")
while true {
if let res = recvSimpleXMsg() {
logger.debug("NotificationService receiveMessages: \(res.responseType)")
switch res {
case let .contactConnected(contact):
return createContactConnectedNtf(contact)
// case let .contactConnecting(contact):
// TODO profile update
case let .receivedContactRequest(contactRequest):
return createContactRequestNtf(contactRequest)
case let .newChatItem(aChatItem):
let cInfo = aChatItem.chatInfo
var cItem = aChatItem.chatItem
if case .image = cItem.content.msgContent {
if let file = cItem.file,
file.fileSize <= maxImageSize,
privacyAcceptImagesGroupDefault.get() {
cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem
}
}
return createMessageReceivedNtf(cInfo, cItem)
// case let .rcvFileComplete(aChatItem):
// TODO file received?
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
default:
logger.debug("NotificationService ignored event: \(res.responseType)")
}
} else {
return nil
// let res = chatResponse(chat_recv_msg(getChatCtrl())!)
let res = await recvSimpleXMsg()
logger.debug("NotificationService receiveMessages: \(res.responseType)")
switch res {
// case let .newContactConnection(connection):
// case let .contactConnectionDeleted(connection):
case let .contactConnected(contact):
return createContactConnectedNtf(contact)
// case let .contactConnecting(contact):
// TODO profile update
case let .receivedContactRequest(contactRequest):
return createContactRequestNtf(contactRequest)
// case let .contactUpdated(toContact):
// TODO profile updated
case let .newChatItem(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
return createMessageReceivedNtf(cInfo, cItem)
// case let .chatItemUpdated(aChatItem):
// TODO message updated
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
// case let .chatItemDeleted(_, toChatItem):
// TODO message updated
// case let .rcvFileComplete(aChatItem):
// TODO file received?
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
default:
logger.debug("NotificationService ignored event: \(res.responseType)")
}
}
}
func apiGetActiveUser() -> User? {
let _ = getChatCtrl()
let r = sendSimpleXCmd(.showActiveUser)
func apiGetActiveUser() async -> User? {
// let _ = getChatCtrl()
let r = await sendSimpleXCmd(.showActiveUser)
logger.debug("apiGetActiveUser sendSimpleXCmd responce: \(String(describing: r))")
switch r {
case let .activeUser(user): return user
@@ -155,8 +138,8 @@ func apiGetActiveUser() -> User? {
}
}
func apiStartChat() throws {
let r = sendSimpleXCmd(.startChat(subscribe: false))
func apiStartChat() async throws {
let r = await sendSimpleXCmd(.startChat)
switch r {
case .chatStarted: return
case .chatRunning: return
@@ -164,30 +147,8 @@ func apiStartChat() throws {
}
}
func apiSetFilesFolder(filesFolder: String) throws {
let r = sendSimpleXCmd(.setFilesFolder(filesFolder: filesFolder))
func apiSetFilesFolder(filesFolder: String) async throws {
let r = await sendSimpleXCmd(.setFilesFolder(filesFolder: filesFolder))
if case .cmdOk = r { return }
throw r
}
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo))
if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r {
return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages)
}
logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)")
return nil
}
func apiReceiveFile(fileId: Int64) -> AChatItem? {
let r = sendSimpleXCmd(.receiveFile(fileId: fileId))
if case let .rcvFileAccepted(chatItem) = r { return chatItem }
logger.error("receiveFile error: \(responseError(r))")
return nil
}
struct NtfMessages {
var connEntity: ConnectionEntity?
var msgTs: Date?
var ntfMessages: [NtfMsgInfo]
}

View File

@@ -0,0 +1,58 @@
//
// FileProviderEnumerator.swift
// SimpleX Service
//
// Created by Evgeny on 01/06/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import FileProvider
import SimpleXServiceProtocol
class FileProviderEnumerator: NSObject, NSFileProviderEnumerator {
var enumeratedItemIdentifier: NSFileProviderItemIdentifier
init(enumeratedItemIdentifier: NSFileProviderItemIdentifier) {
logger.debug("FileProviderExtension FileProviderEnumerator.init")
self.enumeratedItemIdentifier = enumeratedItemIdentifier
super.init()
}
func identifierForItemAtURL(_ url: URL, completionHandler: @escaping (NSFileProviderItemIdentifier) -> Void) {
// logger.debug("FileProviderExtension.identifierForItemAtURL")
completionHandler(SERVICE_PROXY_ITEM_ID)
}
func invalidate() {
// TODO: perform invalidation of server connection if necessary
}
func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) {
/* TODO:
- inspect the page to determine whether this is an initial or a follow-up request
If this is an enumerator for a directory, the root container or all directories:
- perform a server request to fetch directory contents
If this is an enumerator for the active set:
- perform a server request to update your local database
- fetch the active set from your local database
- inform the observer about the items returned by the server (possibly multiple times)
- inform the observer that you are finished with this page
*/
}
func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) {
/* TODO:
- query the server for updates since the passed-in sync anchor
If this is an enumerator for the active set:
- note the changes in your local database
- inform the observer about item deletions and updates (modifications + insertions)
- inform the observer when you have finished enumerating up to a subsequent sync anchor
*/
}
}

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