diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 3e5a93039c8..c49c60afe60 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -46,5 +46,5 @@ jobs: git config user.name "${{ github.actor }}" git config user.email "${{ github.actor }}@users.noreply.github.com" git add . - git commit -m "chore: format code and fix lint issues [skip ci]" + git commit -m "chore: format code and fix lint issues" git push origin ${{ github.ref_name }} \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index f0f06675f4c..f47eee7bc95 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -148,6 +148,7 @@ dependencies { implementation "com.google.code.gson:gson:2.8.9" implementation "com.tencent:mmkv-static:1.2.10" + implementation "com.github.bumptech.glide:glide:${rootProject.ext.glideVersion}" implementation 'com.facebook.soloader:soloader:0.10.4' // For SecureKeystore (EncryptedSharedPreferences) diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index e9998ff8231..43489b5dd2f 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -57,7 +57,7 @@ private MMKV getMMKV() { * Helper method to build avatar URI from avatar path. * Validates server URL and credentials, then constructs the full URI. */ - private String buildAvatarUri(String avatarPath, String errorContext) { + private String buildAvatarUri(String avatarPath, String errorContext, int sizePx) { String server = serverURL(); if (server == null || server.isEmpty()) { Log.w(TAG, "Cannot generate " + errorContext + " avatar URI: serverURL is null"); @@ -67,7 +67,7 @@ private String buildAvatarUri(String avatarPath, String errorContext) { String userToken = token(); String uid = userId(); - String finalUri = server + avatarPath + "?format=png&size=100"; + String finalUri = server + avatarPath + "?format=png&size=" + sizePx; if (!userToken.isEmpty() && !uid.isEmpty()) { finalUri += "&rc_token=" + userToken + "&rc_uid=" + uid; } @@ -102,15 +102,37 @@ public String getAvatarUri() { } } - return buildAvatarUri(avatarPath, ""); + return buildAvatarUri(avatarPath, "", 100); } /** - * Generates avatar URI for video conference caller. + * Factory for building caller avatar URIs from host + username (e.g. VoIP payload). + * Caller is package-private, so this is the only way to get avatar URI from outside the package. + */ + public static Ejson forCallerAvatar(String host, String username) { + if (host == null || host.isEmpty() || username == null || username.isEmpty()) { + return null; + } + Ejson ejson = new Ejson(); + ejson.host = host; + ejson.caller = new Caller(); + ejson.caller.username = username; + return ejson; + } + + /** + * Generates avatar URI for video conference caller (default size 100). * Returns null if caller username is not available (username is required for avatar endpoint). */ public String getCallerAvatarUri() { - // Check if caller exists and has username (required - /avatar/{userId} endpoint doesn't exist) + return getCallerAvatarUri(100); + } + + /** + * Generates avatar URI for video conference caller with custom size. + * Returns null if caller username is not available. + */ + public String getCallerAvatarUri(int sizePx) { if (caller == null || caller.username == null || caller.username.isEmpty()) { Log.w(TAG, "Cannot generate caller avatar URI: caller or username is null"); return null; @@ -118,7 +140,7 @@ public String getCallerAvatarUri() { try { String avatarPath = "/avatar/" + URLEncoder.encode(caller.username, "UTF-8"); - return buildAvatarUri(avatarPath, "caller"); + return buildAvatarUri(avatarPath, "caller", sizePx); } catch (UnsupportedEncodingException e) { Log.e(TAG, "Failed to encode caller username", e); return null; diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt index 82da05741c3..fa157c8951b 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt @@ -9,13 +9,18 @@ import android.media.RingtoneManager import android.os.Build import android.os.Bundle import android.view.WindowManager -import android.widget.ImageButton +import android.view.View import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView +import android.widget.FrameLayout import android.util.Log -import androidx.core.content.ContextCompat +import android.view.ViewOutlineProvider +import com.bumptech.glide.Glide import chat.rocket.reactnative.MainActivity import chat.rocket.reactnative.R +import chat.rocket.reactnative.notification.Ejson +import android.graphics.Typeface /** * Full-screen Activity displayed when an incoming VoIP call arrives. @@ -56,6 +61,7 @@ class IncomingCallActivity : Activity() { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) setContentView(R.layout.activity_incoming_call) + applyInterFont() val voipPayload = VoipPayload.fromBundle(intent.extras) if (voipPayload == null || !voipPayload.isVoipIncomingCall()) { @@ -72,15 +78,85 @@ class IncomingCallActivity : Activity() { setupButtons(voipPayload) } + private fun applyInterFont() { + val interRegular = try { + Typeface.createFromAsset(assets, "fonts/Inter-Regular.otf") + } catch (e: Exception) { + Log.e(TAG, "Failed to load Inter-Regular font", e) + return + } + val interBold = try { + Typeface.createFromAsset(assets, "fonts/Inter-Bold.otf") + } catch (e: Exception) { + Log.e(TAG, "Failed to load Inter-Bold font", e) + interRegular + } + listOf( + R.id.header_text, + R.id.host_name, + R.id.incoming_call_reject_label, + R.id.incoming_call_accept_label + ).forEach { id -> + findViewById(id)?.setTypeface(interRegular) + } + findViewById(R.id.caller_name)?.setTypeface(interBold) + } + private fun updateUI(payload: VoipPayload) { - val callerView = findViewById(R.id.caller_name) - callerView?.text = payload.caller - - // Try to load avatar if available - // TODO: needs username to load avatar - val avatarView = findViewById(R.id.caller_avatar) - // Avatar loading would require additional data - can be enhanced later - // For now, just show a placeholder or default icon + findViewById(R.id.caller_name)?.text = payload.caller.ifEmpty { getString(R.string.incoming_call_unknown_caller) } + findViewById(R.id.host_name)?.text = payload.hostName.ifEmpty { getString(R.string.incoming_call_unknown_host) } + + loadAvatar(payload) + } + + private fun loadAvatar(payload: VoipPayload) { + if (payload.host.isBlank() || payload.username.isBlank()) return + + val container = findViewById(R.id.avatar_container) + val imageView = findViewById(R.id.avatar) + val sizePx = (120 * resources.displayMetrics.density).toInt().coerceIn(120, 480) + val avatarUrl = Ejson.forCallerAvatar(payload.host, payload.username)?.getCallerAvatarUri(sizePx) + ?: return + val cornerRadiusPx = (8 * resources.displayMetrics.density).toFloat() + + Glide.with(this) + .load(avatarUrl) + .into(object : com.bumptech.glide.request.target.CustomTarget(sizePx, sizePx) { + override fun onResourceReady( + resource: android.graphics.drawable.Drawable, + transition: com.bumptech.glide.request.transition.Transition? + ) { + container.visibility = View.VISIBLE + imageView.setImageDrawable(resource) + applyAvatarRoundCorners(imageView, cornerRadiusPx) + } + + override fun onLoadFailed(errorDrawable: android.graphics.drawable.Drawable?) { + container.visibility = View.GONE + } + + override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) { + container.visibility = View.GONE + } + }) + } + + /** + * Applies rounded corners via view-level clipping. + * Works for both PNG (BitmapDrawable) and SVG (vector/PictureDrawable) since + * Glide's RoundedCorners bitmap transform only applies to bitmaps. + */ + private fun applyAvatarRoundCorners(imageView: ImageView, cornerRadiusPx: Float) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return + imageView.post { + val radius = cornerRadiusPx + imageView.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: android.graphics.Outline) { + outline.setRoundRect(0, 0, view.width, view.height, radius) + } + } + imageView.clipToOutline = true + } } private fun startRingtone() { @@ -105,14 +181,11 @@ class IncomingCallActivity : Activity() { } private fun setupButtons(payload: VoipPayload) { - val acceptButton = findViewById(R.id.btn_accept) - val declineButton = findViewById(R.id.btn_decline) - - acceptButton?.setOnClickListener { + findViewById(R.id.btn_accept)?.setOnClickListener { handleAccept(payload) } - declineButton?.setOnClickListener { + findViewById(R.id.btn_decline)?.setOnClickListener { handleDecline(payload) } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt index d1ffa2e53d7..e9971eca91a 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt @@ -5,7 +5,6 @@ import com.google.gson.annotations.SerializedName import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import chat.rocket.reactnative.utils.CallIdUUID -import android.util.Log data class VoipPayload( @SerializedName("callId") @@ -13,12 +12,18 @@ data class VoipPayload( @SerializedName("caller") val caller: String, + + @SerializedName("username") + val username: String, @SerializedName("host") val host: String, @SerializedName("type") - val type: String + val type: String, + + @SerializedName("hostName") + val hostName: String, ) { val notificationId: Int = callId.hashCode() val callUUID: String = CallIdUUID.generateUUIDv5(callId) @@ -31,8 +36,10 @@ data class VoipPayload( return Bundle().apply { putString("callId", callId) putString("caller", caller) + putString("username", username) putString("host", host) putString("type", type) + putString("hostName", hostName) putString("callUUID", callUUID) putInt("notificationId", notificationId) // Useful flag for MainActivity to know it's handling a VoIP action @@ -44,8 +51,10 @@ data class VoipPayload( return Arguments.createMap().apply { putString("callId", callId) putString("caller", caller) + putString("username", username) putString("host", host) putString("type", type) + putString("hostName", hostName) putString("callUUID", callUUID) putInt("notificationId", notificationId) } @@ -53,24 +62,27 @@ data class VoipPayload( companion object { fun fromMap(data: Map): VoipPayload? { - Log.d("RocketChat.VoipPayload", "Parsing VoIP payload from map: $data") val type = data["type"] ?: return null val callId = data["callId"] ?: return null val caller = data["caller"] ?: return null + val username = data["username"] ?: return null val host = data["host"] ?: return null + val hostName = data["hostName"] ?: return null if (type != "incoming_call") return null - return VoipPayload(callId, caller, host, type) + return VoipPayload(callId, caller, username, host, type, hostName) } fun fromBundle(bundle: Bundle?): VoipPayload? { if (bundle == null) return null val callId = bundle.getString("callId") ?: return null - val caller = bundle.getString("caller") ?: "" - val host = bundle.getString("host") ?: "" - val type = bundle.getString("type") ?: "" + val caller = bundle.getString("caller") ?: return null + val username = bundle.getString("username") ?: return null + val host = bundle.getString("host") ?: return null + val type = bundle.getString("type") ?: return null + val hostName = bundle.getString("hostName") ?: return null - return VoipPayload(callId, caller, host, type) + return VoipPayload(callId, caller, username, host, type, hostName) } } } \ No newline at end of file diff --git a/android/app/src/main/res/drawable/bg_avatar_incoming_call.xml b/android/app/src/main/res/drawable/bg_avatar_incoming_call.xml new file mode 100644 index 00000000000..15b9de09786 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_avatar_incoming_call.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_btn_accept.xml b/android/app/src/main/res/drawable/bg_btn_accept.xml new file mode 100644 index 00000000000..e6f8ea9891c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_btn_accept.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_btn_reject.xml b/android/app/src/main/res/drawable/bg_btn_reject.xml new file mode 100644 index 00000000000..8f9083b8279 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_btn_reject.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_call.xml b/android/app/src/main/res/drawable/ic_call.xml new file mode 100644 index 00000000000..6d6192d7ca1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_call.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_call_end.xml b/android/app/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 00000000000..443ec5c0aeb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/layout/activity_incoming_call.xml b/android/app/src/main/res/layout/activity_incoming_call.xml index d025d991c16..ec176db430f 100644 --- a/android/app/src/main/res/layout/activity_incoming_call.xml +++ b/android/app/src/main/res/layout/activity_incoming_call.xml @@ -1,72 +1,175 @@ - - - - - - - - - + android:background="@color/incoming_call_background" + android:orientation="vertical"> - + + android:orientation="horizontal" + android:paddingTop="108dp"> + + + + + + + + + + + + + + + + + + + + + + + - - + + + + - - - + + + + + + + + + + + + + + + + + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="44dp" + android:background="?attr/selectableItemBackground" + android:clickable="true" + android:focusable="true" + android:gravity="center" + android:orientation="vertical" + android:padding="16dp"> + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/colors_incoming_call.xml b/android/app/src/main/res/values-night/colors_incoming_call.xml new file mode 100644 index 00000000000..4e1b5e4533f --- /dev/null +++ b/android/app/src/main/res/values-night/colors_incoming_call.xml @@ -0,0 +1,15 @@ + + + + #1F2329 + #9EA2A8 + #9EA2A8 + #F2F3F5 + #9EA2A8 + #5F1477 + #FFFFFF + #BB3E4E + #1D7256 + #F2F3F5 + #FFFFFF + diff --git a/android/app/src/main/res/values/colors_incoming_call.xml b/android/app/src/main/res/values/colors_incoming_call.xml new file mode 100644 index 00000000000..14984e230a4 --- /dev/null +++ b/android/app/src/main/res/values/colors_incoming_call.xml @@ -0,0 +1,15 @@ + + + + #FFFFFF + #6C727A + #6C727A + #1F2329 + #6C727A + #5F1477 + #FFFFFF + #EC0D2A + #158D65 + #1F2329 + #FFFFFF + diff --git a/android/app/src/main/res/values/strings_incoming_call.xml b/android/app/src/main/res/values/strings_incoming_call.xml new file mode 100644 index 00000000000..bb4905d72da --- /dev/null +++ b/android/app/src/main/res/values/strings_incoming_call.xml @@ -0,0 +1,9 @@ + + + Incoming call… + Reject + Accept + Caller avatar + Unknown caller + Unknown host + diff --git a/android/app/src/main/res/values/styles_incoming_call.xml b/android/app/src/main/res/values/styles_incoming_call.xml index a5a7484c14d..bee85d25875 100644 --- a/android/app/src/main/res/values/styles_incoming_call.xml +++ b/android/app/src/main/res/values/styles_incoming_call.xml @@ -4,7 +4,7 @@ true true false - @android:color/black + @color/incoming_call_background true @android:color/transparent @android:color/transparent diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index d57f47e7fa1..6b0aca2235a 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -46,7 +46,7 @@ const waitForNavigation = () => { if (Navigation.navigationRef.current) { return Promise.resolve(); } - return new Promise(resolve => { + return new Promise((resolve) => { const listener = () => { emitter.off('navigationReady', listener); resolve(); diff --git a/app/sagas/login.js b/app/sagas/login.js index 6e7737a854c..d3c52a6b9a8 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -60,7 +60,7 @@ const showSupportedVersionsWarning = function* showSupportedVersionsWarning(serv const serversDB = database.servers; yield serversDB.write(async () => { - await serverRecord.update(r => { + await serverRecord.update((r) => { r.supportedVersionsWarningAt = new Date(); }); }); @@ -107,7 +107,7 @@ const handleLoginRequest = function* handleLoginRequest({ credentials, logoutOnE } // this is updating on every login just to save `updated_at` // keeping this server as the most recent on autocomplete order - await serverHistoryRecord.update(s => { + await serverHistoryRecord.update((s) => { s.username = result.username; if (iconURL) { s.iconURL = iconURL; @@ -279,12 +279,12 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { yield serversDB.write(async () => { try { const userRecord = await usersCollection.find(user.id); - await userRecord.update(record => { + await userRecord.update((record) => { record._raw = sanitizedRaw({ id: user.id, ...record._raw }, usersCollection.schema); Object.assign(record, u); }); } catch (e) { - await usersCollection.create(record => { + await usersCollection.create((record) => { record._raw = sanitizedRaw({ id: user.id }, usersCollection.schema); Object.assign(record, u); }); @@ -361,7 +361,7 @@ const handleSetUser = function* handleSetUser({ user }) { yield serversDB.write(async () => { try { const record = await userCollections.find(userId); - await record.update(userRecord => { + await record.update((userRecord) => { if ('avatarETag' in user) { userRecord.avatarETag = user.avatarETag; } diff --git a/index.js b/index.js index a59c08135ce..a8ca1d5a6a0 100644 --- a/index.js +++ b/index.js @@ -47,7 +47,7 @@ if (process.env.USE_STORYBOOK) { console.log('RNCallKeep setup successful'); RNCallKeep.canMakeMultipleCalls(false); }) - .catch(error => { + .catch((error) => { console.error('Error setting up RNCallKeep:', error); }); }