In April I wrote about a week where calls alternated between working and killing the client, while Xcode argued with iCloud like it had chosen that as a full-time job. A little over a month later, GChat no longer feels like a build for four pockets. Friends are actually using chats, calling iPhone ↔ Android, filing bugs, and the bugs have become grown-up bugs.
Earlier a complaint sounded like: “something is wrong with audio.” Now it is more like: “on iPhone I hear ringback, Android accepted the call, but there is no voice”, “after accepting the call, the system ringtone kept playing”, “without internet the app behaves as if I am logged out.” That is no longer fog. That is material for an investigation.
Here is what changed between April 27 and the current 0.9.106 build on the pre-release channel. Not a press release, but an engineering diary: what got fixed, what had to be rewritten, and what still honestly hurts.
From beta to pre-release
Externally, the change is small, but it sets expectations. The “Protection” tab became “Settings”, the shield icon became a gear, the footer now says pre-release instead of pre-beta, and support@gchat.tech opens the mail client on tap.
This is still not a store release, but it is no longer “we are just playing around.” The product lives with people, so people need a clear way to say: “this broke here.”
Mac stopped being a phone on a bigger screen
The biggest May shift was real multi-device support.
Before, the logic was closer to “the phone is primary, everything else stands nearby.” Now every device is its own cryptographic identity: its own keys, its own encryption sessions, its own device_id. When I send a message to someone who has an iPhone and a Mac, the server does not receive one shared plaintext and hand out copies. The client encrypts a separate payload for each recipient device.
Put simply: Mac is no longer a mirror of the phone. It is an independent participant in the conversation.
Mac sign-in now has two proper paths:
- Recovery Phrase — restore by decrypting identity material from a phrase.
- Phone Export — QR pairing from the phone, after which the Mac lives on its own.
Phone and Mac can work at the same time. This is closer to Signal than to “open the web version and scan a QR every evening.”
The cost was expected: mobile send and receive logic had to be rewritten, deduplication had to be tightened, and decryption had to work when the other person has two active devices. We did catch one honest regression: messages between phones stopped decrypting. That immediately became the priority, because E2E without decryption is not a messenger. It is an expensive form of silence.
Separately, the iOS app running on macOS via “iPhone apps on Mac” now shows a full-screen placeholder. The macOS version will be a separate client, not a hack on top of the iPad build.
Profiles and People are no longer just contacts
For a long time, a GChat profile was almost a service card: avatar plus @handle. In May it started feeling like a real social layer.
We added:
- bio in settings and profile cards;
- avatar history, so older photos do not disappear after changing the main one;
- the People tab with photos, @tags, last-seen time, verification checks, and stories;
- opening a profile by tapping the avatar in a 1:1 chat header;
- quick “message” and “call” actions from the profile.
The backend got migrations for bio and avatar history. On paper it sounds like UI work, but the feeling matters more. GChat stops being “chats with cryptography” and becomes an app where people have presence. Encryption stays under the hood, but users should not have to remember every minute that they are inside an engineering experiment.
Chat: less “it works”, more “I can live here”
Alongside profiles, a lot of daily-use details landed:
- scroll-to-bottom button;
- floating date while scrolling;
- micro-animations and haptic feedback;
- message multi-select;
- local chat search;
- reply previews;
- message editing without breaking the E2E protocol;
- optimized photo previews and groundwork for thumbnail cache.
Some of it is boring, which is why it matters: SQLite archive for old messages, local index, media cache, transit-only backend. The server does not store chat history; it handles delivery. That is good for privacy, but it moves responsibility to the client: search, cache, archive, and state restoration have to work on the device.
This is where a messenger becomes mature not through one shiny feature, but through the sum of small things. When the date does not jump, photos do not eat memory, search finds an old phrase, and replies show previews, your brain stops noticing the interface. That is the goal.
Calls: from “maybe the network is bad” to diagnosis
In the April post the pain was blunt: calls could crash the client because of flutter_webrtc. In May the problem became subtler. The app no longer crashed, but audio quality and routing could still behave strangely:
- iPhone calls Android, ringback plays, but nobody hears a voice;
- speakerphone shows as enabled during a video call, but audio stays in the earpiece;
- CallKit keeps playing the system ringtone even after the user accepts the call inside the app.
The investigation landed in the intersection of AVAudioSession, ringback audio, and flutter_webrtc. Without the Apple-specific magic: iOS is very protective of audio. While the app plays ringback, the system may hold one audio mode; when WebRTC tries to take over mic and speaker, the app has to reassert that this is now a VoIP call, not a regular player.
The fix: a native iOS MethodChannel that reactivates the VoIP session and explicitly switches output. Then we reassert the audio route right after ringback stops, without waiting for connected.
In parallel, the client got a more serious WebRTC layer:
- live
getStats()collection — RTT, bitrate, candidate path, fps, loss; - debug overlay on the call screen via long-press;
- TURN/ICE prewarm when opening a 1:1 chat;
- caps on video bitrate and framerate;
- guards against renegotiation and ICE restart conflicts;
- smarter ICE cache so a bad NAT path is not preserved for ten minutes.
Passive telemetry is the big win
Most importantly, we stopped guessing why a call fell apart and started collecting evidence.
The client sends the backend no audio and no call content, only technical quality traces:
- samples roughly every 15 seconds: RTT, kbps in/out, relay/srflx path, fps, loss, jitter;
- summary on teardown: connect time, zero-audio seconds, relay seconds, ICE restarts, failure reason;
- lifecycle events:
offer_sent,call_accepted,answer_received,ice_connected,connected,ice_restart_*, watchdog timeouts.
The backend stores this in call_quality_samples, call_quality_summary, and call_quality_events. For investigations there is an admin endpoint, GET /v1/admin/calls/{call_id}/quality: both sides of the call, timeline, samples, events, and a preliminary diagnosis.
Now “they cannot hear me” turns into facts:
- caller has
audio_out = 0— most likely mic or audio session on that device; - caller has
audio_out > 0, callee hasaudio_in = 0— inspect network, ICE, or TURN; - both sides have
audio_in > 0, but the complaint is still real — likely output route or audio focus.
That is a huge difference. Without telemetry, you argue with feelings. With telemetry, you look at two devices and see where the signal died.
Next step: an admin dashboard with filters and charts. JSON endpoint plus SQL is enough for now, but I do not want to live there forever.
Network, offline, and “Connecting…”
Another beta pain: open the app without network and it throws you into registration, even though you are already logged in. Now the session restores from secure storage, “Connecting…” appears, and cached chats stay available. The same indicator appears in an open chat header when the socket drops.
This is not the loudest feature, but it is one of the most human ones. Users do not need to understand that the backend is unavailable, WebSocket is reconnecting, and tokens live separately from UI. They need to open the app and see: “I am still here, my data did not vanish, the network will come back.”
CallKit also became calmer: the system call UI is dismissed when the user accepts inside the app. Otherwise you get two ringtones and the feeling that the phone is arguing with itself.
Push notifications moved to Variant A: the notification contains a message-type hint, but no text and no private content. That gives iOS and Android enough to render something useful without turning push into a leak.
Media: mini-player across the shell
There is now a bottom mini-player across the whole shell: chats, people, calls, settings. It has cover art, marquee title, play/pause, a progress line, and compact controls. It works for playlist music and for voice messages in chats.
As a ticket, this sounds almost decorative. In daily use, it is not. When a voice message keeps playing while you move between screens, and music does not disappear after every tap, the app starts feeling whole. Not “open, send, close,” but a place where you can spend a few minutes without hitting rough edges.
What is still open
It is fine to talk about more than what worked.
- Video circles still hit blob limits and need proper transcode for circle quality. A long high-bitrate clip may fail to send.
- Mac client is in active development. Mobile fan-out and recovery paths exist, but desktop is not in the store yet.
- Call-quality dashboard does not exist yet. JSON exists; a useful investigation UI does not.
- Retention and call aggregates — daily failed %, p95 connect time, and similar metrics — are planned.
- Thumbnail-cache and media-index for the chat gallery remain the next big win for memory and speed.
The month after the April beta was not about perfection. It was about moving from “it seems to work on my phone” to a GChat with independent devices, real profiles, honest offline behavior, and calls you can investigate.
I like this stage. It is less romantic, because bugs become specific and sometimes unpleasant. But this is where a product stops being a demo and starts behaving like something you are not embarrassed to use every day.