SDK Mobile · v1.0

GPSAI Chatbot
Intégration Mobile

Guide d'intégration complet pour embarquer l'assistant conversationnel GPSAI dans votre application Android ou iOS, en utilisant Stream Chat comme couche de transport.

01Vue d'ensemble

GPSAI est un assistant conversationnel arabe/français qui permet aux propriétaires de flotte d'interroger leurs véhicules en langage naturel (« وين voiture 4 توا ؟ », « قداش السرعة »…). Les applications mobiles communiquent avec le bot via Stream Chat ; tout le traitement IA se fait côté serveur et arrive sous forme de messages chat classiques.

💡
Pourquoi Stream Chat Le client mobile ne parle jamais directement au moteur IA. Vous ne manipulez que le SDK Stream — envoi et réception de messages. Les réponses du bot arrivent comme des messages chat standards avec des pièces jointes optionnelles (quick replies, cartes, etc.).

02Architecture

Le flux complet d'un message implique quatre acteurs :

01
App Mobile
Envoie le message utilisateur via le SDK Stream
02
Serveur Stream
Persiste le message, déclenche le webhook
03
Backend GPSAI
Appelle l'AI Engine, formate la réponse
04
Réponse du bot
Poussée vers le mobile en temps réel

Pour le développeur mobile, seules les étapes 01 et 04 sont visibles. Le SDK Stream gère automatiquement la livraison, l'ordre, le cache hors-ligne, les indicateurs de saisie et les accusés de lecture.

03Prérequis

Ce dont vous avez besoin de l'équipe GPS Tunisie

  • Une URL de base du backend (ex : http://plat.gps-tunisie.com:8080/api)
  • Un ID utilisateur GPS authentifié (entier, issu de votre flow de login existant)
  • L'ID utilisateur du bot — par défaut user_1

Vous n'avez pas besoin de connaître la clé API Stream — le backend la retourne avec le token à chaque authentification.

04Installer le SDK Stream

build.gradle (Module)
dependencies {
    // Stream Chat UI Components
    implementation "io.getstream:stream-chat-android-ui-components:6.4.0"
    implementation "io.getstream:stream-chat-android-offline:6.4.0"
    implementation "io.getstream:stream-chat-android-state:6.4.0"

    // HTTP client for auth token call
    implementation "com.squareup.okhttp3:okhttp:4.12.0"
}
Podfile
target 'YourApp' do
  use_frameworks!

  # Stream Chat SDK
  pod 'StreamChat', '~> 4.0'
  pod 'StreamChatUI', '~> 4.0'
end
Package.swift (SPM alternative)
.package(
  url: "https://github.com/GetStream/stream-chat-swift",
  from: "4.0.0"
)

Permissions requises

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <!-- voice only -->
Info.plist
<key>NSMicrophoneUsageDescription</key>
<string>Used for voice messages with GPSAI assistant</string>

05Obtenir le jeton d'authentification

Avant de se connecter à Stream, demandez un JWT à durée limitée et la clé API publique Stream au backend GPSAI. Passez l'ID utilisateur GPS que vous avez déjà depuis votre propre système de login.

POST {BACKEND_URL}/chatbot_stream_backend.php?action=token

Corps de la requête

JSON
{
  "user_id": "user_42",        // "user_" + gpsUserId
  "gps_user_id": "42"          // raw GPS user ID
}

Réponse

JSON
{
  "token":     "eyJhbGciOi...",    // JWT (24h validity)
  "api_key":   "nwz6q327uhqy",       // public Stream API key
  "bot_id":    "user_1",             // bot user ID for channel
  "user_id":   "user_42",
  "gps_user_id": "42"
}
Champ Rôle
token RequisÀ passer à connectUser() dans le SDK Stream.
api_key RequisClé Stream publique pour instancier ChatClient. Ne la hardcodez jamais — lisez-la toujours depuis cette réponse.
bot_id RequisID utilisateur du bot à inclure comme membre lors de la création du canal.

Implémentation

Java · ChatbotActivity.java
private void fetchToken() {
    new Thread(() -> {
        try {
            OkHttpClient client = new OkHttpClient();

            JSONObject body = new JSONObject();
            body.put("user_id",     "user_" + gpsUserId);
            body.put("gps_user_id", gpsUserId);

            Request request = new Request.Builder()
                .url(BACKEND_URL + "/chatbot_stream_backend.php?action=token")
                .post(RequestBody.create(
                    MediaType.parse("application/json"),
                    body.toString()))
                .build();

            Réponse response = client.newCall(request).execute();
            JSONObject json = new JSONObject(response.body().string());

            String token  = json.getString("token");
            String apiKey = json.getString("api_key");
            String botId  = json.getString("bot_id");

            runOnUiThread(() -> initChat(apiKey, token, botId));
        } catch (Exception e) {
            Log.e(TAG, "Token fetch failed", e);
        }
    }).start();
}
Swift
struct GPSAuthRéponse: Decodable {
    let token: String
    let apiKey: String
    let botId: String

    enum CodingKeys: String, CodingKey {
        case token
        case apiKey = "api_key"
        case botId  = "bot_id"
    }
}

func fetchToken(gpsUserId: String) async throws -> GPSAuthRéponse {
    var request = URLRequest(url: URL(string: "\(backendURL)/chatbot_stream_backend.php?action=token")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let body = [
        "user_id":     "user_\(gpsUserId)",
        "gps_user_id": gpsUserId
    ]
    request.httpBody = try JSONSerialization.data(withJSONObject: body)

    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(GPSAuthRéponse.self, from: data)
}
Ne pas mettre le token en cache entre utilisateurs Chaque gps_user_id a son propre JWT. Si un utilisateur se déconnecte et qu'un autre se connecte, redemandez un nouveau token. Le token est valable 24 heures.

06Connecter l'utilisateur à Stream

Avec le token en main, construisez un ChatClient et connectez l'utilisateur. Stream maintient un WebSocket persistant pour les mises à jour temps réel.

Java
private void initChat(String apiKey, String token, String botId) {

    // Required plugins for SDK 6.x
    StreamOfflinePluginFactory offlinePlugin =
        new StreamOfflinePluginFactory(getApplicationContext());

    StreamStatePluginFactory statePlugin =
        new StreamStatePluginFactory(
            new StatePluginConfig(true, true),
            getApplicationContext());

    ChatClient client = new ChatClient.Builder(apiKey, getApplicationContext())
        .withPlugins(offlinePlugin, statePlugin)
        .build();

    User user = new User.Builder()
        .withId("user_" + gpsUserId)
        .withName("Utilisateur " + gpsUserId)
        .build();

    client.connectUser(user, token).enqueue(result -> {
        if (result.isSuccess()) {
            createOrOpenChannel(client, botId);
        } else {
            Log.e(TAG, "Connect failed: " + result.errorOrNull());
        }
    });
}
Swift
import StreamChat

func initChat(auth: GPSAuthRéponse, gpsUserId: String) {
    let config = ChatClientConfig(apiKey: .init(auth.apiKey))
    let client = ChatClient(config: config)

    let userInfo = UserInfo(
        id:   "user_\(gpsUserId)",
        name: "Utilisateur \(gpsUserId)"
    )

    client.connectUser(
        userInfo: userInfo,
        token: try! Token(rawValue: auth.token)
    ) { error in
        if let error = error {
            print("Connect failed: \(error)")
        } else {
            self.openChannel(client: client, botId: auth.botId, gpsUserId: gpsUserId)
        }
    }
}

07Ouvrir le canal de chat

Créez un canal de type messaging avec un ID déterministe basé sur l'utilisateur. Le bot doit être ajouté comme membre, et le gps_user_id doit être placé dans extraData — le webhook backend le lit pour router correctement les requêtes vers l'AI Engine.

Convention Valeur
Type de canalmessaging
ID du canalgps_chat_user_{gpsUserId}
Membresuser_{gpsUserId} + user_1 (le bot)
Données extra{ "gps_user_id": "42", "name": "Assistant GPS" }
Java
private void createOrOpenChannel(ChatClient client, String botId) {
    String channelId = "gps_chat_user_" + gpsUserId;

    Map<String, Object> extraData = new HashMap<>();
    extraData.put("gps_user_id", gpsUserId);
    extraData.put("name",        "Assistant GPS");

    client.channel("messaging", channelId)
        .create(Arrays.asList("user_" + gpsUserId, botId), extraData)
        .enqueue(result -> {
            if (result.isSuccess()) {
                String cid = "messaging:" + channelId;
                startActivity(CustomChatActivity.newIntent(this, cid));
                finish();
            }
        });
}
Swift
func openChannel(client: ChatClient, botId: String, gpsUserId: String) {
    let channelId = ChannelId(type: .messaging, id: "gps_chat_user_\(gpsUserId)")

    let controller = try! client.channelController(
        createChannelWithId: channelId,
        name: "Assistant GPS",
        members: ["user_\(gpsUserId)", botId],
        isCurrentUserMember: true,
        extraData: ["gps_user_id": .string(gpsUserId)]
    )

    controller.synchronize { error in
        guard error == nil else { return }
        // Push your ChatViewController with `controller`
        let chatVC = ChatViewController(channelController: controller)
        navigationController?.pushViewController(chatVC, animated: true)
    }
}
gps_user_id doit être renseigné Si extraData["gps_user_id"] est absent, le webhook backend ne peut pas identifier quelle flotte interroger. Le bot répondra mais les données seront celles de l'utilisateur 1 (fallback par défaut).

08Envoyer des messages

Utilisez l'API standard d'envoi de message du SDK Stream. Le bot répondra automatiquement en 1 à 3 secondes selon la charge de l'AI Engine.

Java
private void sendMessage(String text) {
    Message message = new Message.Builder()
        .withText(text)
        .build();

    ChatClient.instance()
        .channel("messaging", channelId)
        .sendMessage(message, false)
        .enqueue(result -> {
            if (result.isSuccess()) {
                Log.d(TAG, "Sent: " + text);
            }
        });
}
Swift
func sendMessage(_ text: String) {
    channelController.createNewMessage(text: text) { result in
        switch result {
        case .success(let messageId):
            print("Sent: \(messageId)")
        case .failure(let error):
            print("Error: \(error)")
        }
    }
}

Réception des réponses du bot

Pas besoin de polling ni d'appel à un endpoint — le SDK Stream pousse les messages du bot via le même WebSocket. Bind un MessageListView (Android) ou utilisez un ChannelControllerDelegate (iOS) et les nouveaux messages apparaîtront automatiquement.

09Messages vocaux facultatif

Les messages vocaux utilisent une architecture en deux pistes parallèles : l'enregistrement est envoyé immédiatement comme un message Stream portant le chemin du fichier local dans extraData (pour que l'utilisateur puisse le réécouter dans le chat), et en parallèle l'audio est encodé en base64 puis POSTé au backend pour la transcription et le traitement IA. Le backend injecte ensuite la transcription comme message utilisateur, puis la réponse du bot.

🔀
Pourquoi deux pistes Stream n'héberge pas le fichier audio — il porte seulement des métadonnées. L'audio vit sur l'appareil pour la lecture locale et sur le backend pour le traitement IA. Cela garde les messages Stream légers et fonctionne sans CDN public.

Flow d'enregistrement

  1. Tap sur le micro → enregistrement vers un emplacement permanent (filesDir/voice_messages/)
  2. Échantillonnage des amplitudes audio toutes les 80 ms (pour la waveform)
  3. Stop → envoi du message Stream avec extraData + POST du base64 au backend
  4. Le backend transfère à l'AI Engine, reçoit transcript + réponse du bot, poste les deux dans le canal

Enregistrement de l'audio

Java · VoiceRecorder.java
// MPEG_4 / AAC, 16 kHz mono, 64 kbps — small and Whisper-friendly
recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
recorder.setAudioSamplingRate(16000);
recorder.setAudioEncodingBitRate(64000);

// Store in filesDir (NOT cacheDir — file must survive for replay)
File dir = new File(context.getFilesDir(), "voice_messages");
String path = new File(dir, "voice_" + System.currentTimeMillis() + ".m4a")
    .getAbsolutePath();
recorder.setOutputFile(path);
recorder.prepare();
recorder.start();

// Sample amplitudes every 80ms for the waveform
handler.postDelayed(() -> amplitudes.add(recorder.getMaxAmplitude()), 80);
Swift · using AVAudioRecorder
import AVFoundation

let settings: [String: Any] = [
    AVFormatIDKey:         kAudioFormatMPEG4AAC,
    AVSampleRateKey:       16000,
    AVNumberOfChannelsKey: 1,
    AVEncoderBitRateKey:   64000
]

let dir = FileManager.default
    .urls(for: .documentDirectory, in: .userDomainMask)[0]
    .appendingPathComponent("voice_messages")
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)

let url = dir.appendingPathComponent("voice_\(Int(Date().timeIntervalSince1970)).m4a")
recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.isMeteringEnabled = true     // for amplitudes
recorder.prepareToRecord()
recorder.record()

// Sample amplitudes every 0.08s
Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in
    recorder.updateMeters()
    amplitudes.append(Int(recorder.averagePower(forChannel: 0) * 10000))
}

Envoi du message vocal (deux pistes)

POST {BACKEND_URL}/chatbot_stream_backend.php?action=voice
JSON · Corps de la requête (piste base64)
{
  "user_id":      "42",
  "channel_id":   "gps_chat_user_42",
  "audio_base64": "AAAAGGZ0eXBtcDQy..."
}

Le message côté Stream porte les métadonnées de lecture dans extraData :

JSON · extraData du message Stream (piste UI)
{
  "text": "🎙️ Voice",
  "extraData": {
    "is_voice":          true,
    "voice_local_path":  "/data/.../voice_1716234567.m4a",
    "voice_duration_ms": 4280,
    "voice_amplitudes":  [1240, 3890, 5120, 4200, 2100, ...]
  }
}
Champ extraDataRôle
is_voice RequisFlag booléen — le ViewHolder factory l'utilise pour rendre une bulle vocale au lieu d'une bulle texte.
voice_local_path RequisChemin absolu vers le fichier audio local. Utilisé par MediaPlayer / AVAudioPlayer pour la lecture.
voice_duration_ms RequisDurée totale en millisecondes (affichée en 00:04).
voice_amplitudes OptionnelÉchantillons d'amplitude bruts pour la visualisation de la waveform. Chaque valeur est un pic capturé à intervalle de ~80 ms.
Java · sendVoiceMessage()
private void sendVoiceMessage(String path, long durationMs, ArrayList<Integer> amps) {

    // ── TRACK 1: Stream message with extraData (instant UI) ──
    HashMap<String, Object> extra = new HashMap<>();
    extra.put("is_voice",          true);
    extra.put("voice_local_path",  path);
    extra.put("voice_duration_ms", durationMs);
    extra.put("voice_amplitudes",  amps);

    Message msg = new Message.Builder()
        .withText("🎙️ Voice")
        .withExtraData(extra)
        .build();

    ChatClient.instance()
        .channel(channelType, channelId)
        .sendMessage(msg, false)
        .enqueue(r -> {});

    // ── TRACK 2: base64 to backend (for AI processing) ──
    new Thread(() -> {
        String base64 = VoiceRecorder.fileToBase64(path);

        JSONObject body = new JSONObject();
        body.put("user_id",      gpsUserId);
        body.put("channel_id",   channelId);
        body.put("audio_base64", base64);

        // POST { user_id, channel_id, audio_base64 } to ?action=voice
        // ⚠️ Do NOT delete the file here — local playback needs it!
    }).start();
}
Swift
func sendVoiceMessage(path: URL, durationMs: Int, amps: [Int]) {

    // ── TRACK 1: Stream message with extraData ──
    let extra: [String: RawJSON] = [
        "is_voice":          .bool(true),
        "voice_local_path":  .string(path.path),
        "voice_duration_ms": .number(Double(durationMs)),
        "voice_amplitudes":  .array(amps.map { .number(Double($0)) })
    ]

    channelController.createNewMessage(text: "🎙️ Voice", extraData: extra)

    // ── TRACK 2: base64 to backend ──
    Task.detached {
        let data = try Data(contentsOf: path)
        let base64 = data.base64EncodedString()

        var req = URLRequest(url: URL(string: "\(backendURL)/chatbot_stream_backend.php?action=voice")!)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try JSONSerialization.data(withJSONObject: [
            "user_id": gpsUserId,
            "channel_id": channelId,
            "audio_base64": base64
        ])
        _ = try await URLSession.shared.data(for: req)
    }
}
Ne supprimez pas le fichier local Le message Stream le référence par chemin absolu. Si vous supprimez le fichier après l'envoi, la bulle vocale ne pourra plus être lue. Planifiez un nettoyage seulement pour les très vieux enregistrements (ex : > 7 jours) via un job en arrière-plan.

Lecture audio

Quand un message arrive avec extraData.is_voice == true, affichez une bulle vocale (bouton play + waveform + durée). Au tap, utilisez un singleton de lecture pour garantir qu'un seul audio joue à la fois.

Java · VoicePlayerManager (singleton)
public class VoicePlayerManager {
    private static VoicePlayerManager INSTANCE;
    private MediaPlayer player;
    private String currentPath;

    public boolean toggle(String path, Listener listener) {
        // Same file → pause/resume; different file → stop old, play new
        if (path.equals(currentPath) && player != null) {
            if (player.isPlaying()) {
                player.pause();
                return false;
            }
            player.start();
            return true;
        }
        releaseInternal();
        player = new MediaPlayer();
        player.setDataSource(path);
        player.prepare();
        player.start();
        currentPath = path;
        return true;
    }
}

// In your VoiceUserHolder:
btnPlayPause.setOnClickListener(v -> {
    boolean playing = VoicePlayerManager.get().toggle(localPath, listener);
    btnPlayPause.setImageResource(playing ? R.drawable.ic_pause : R.drawable.ic_play);
});
Swift · VoicePlayerManager (singleton)
class VoicePlayerManager {
    static let shared = VoicePlayerManager()
    private var player: AVAudioPlayer?
    private var currentPath: String?

    func toggle(_ path: String, listener: VoiceListener) -> Bool {
        if currentPath == path, let p = player {
            if p.isPlaying { p.pause(); return false }
            p.play(); return true
        }
        player?.stop()
        player = try? AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
        player?.play()
        currentPath = path
        return true
    }
}

Ce que le backend fait avec l'audio

  1. Reçoit audio_base64 + channel_id + user_id
  2. Transfère l'audio base64 à l'AI Engine via POST /chatbot/message (l'audio remplace le texte dans le champ message)
  3. L'AI Engine retourne { transcript: "...", message: "...", suggestions: [...] }
  4. Le backend poste la transcription comme message Stream depuis l'utilisateur ("🎙️ {transcript}") pour qu'elle apparaisse dans l'historique
  5. Le backend poste la réponse du bot comme message Stream depuis user_1
🎙
Format audio requis AAC dans un conteneur MPEG-4, mono, 16 kHz, 64 kbps. Gardez les enregistrements sous 60 secondes. Le client mobile doit faire respecter MIN_RECORDING_MS = 500 pour rejeter les taps accidentels.

10Référence des endpoints API

Le client mobile n'appelle que ces deux endpoints — toutes les autres interactions passent par le SDK Stream.

MéthodeEndpointRôle
POST ?action=token Obtenir JWT + api_key + bot_id
POST ?action=voice Uploader l'audio pour transcription & réponse

11Format des messages

Les réponses du bot arrivent comme des messages Stream standards. Le champ text contient du Markdown — rendez-le (gras, italique, liens) pour une meilleure UX.

Exemple de message bot · corps markdown
📍 *voiture 4*

🏠 25 جويلية, معتمدية سيدي حسين, تونس
🏙 المدينة: تونس
⏱ آخر تحديث: 2026-05-20 14:32:18
📊 الحالة: En mouvement (4min 9s)

_IMEI sélectionné: 355710091342167_
_Véhicule sélectionné: voiture 4_

Identifiez les messages du bot par leur expéditeur :

Java · vérifier l'expéditeur
boolean isBot = "user_1".equals(message.getUser().getId());

Ou en Swift :

Swift
let isBot = message.author.id == "user_1"

Messages vocaux

Un message vocal se détecte par la présence de extraData.is_voice == true. Le factory doit afficher une bulle vocale (bouton play + waveform + durée) au lieu de la bulle texte standard :

Java · routing dans votre ViewHolderFactory
if (item instanceof MessageListItem.MessageItem) {
    Message m = ((MessageListItem.MessageItem) item).getMessage();

    boolean isBot   = botId.equals(m.getUser().getId());
    Object  voice   = m.getExtraData() != null
                       ? m.getExtraData().get("is_voice") : null;
    boolean isVoice = voice instanceof Boolean && (Boolean) voice;

    if (!isBot && isVoice) return TYPE_VOICE_USER;
    return isBot ? TYPE_BOT : TYPE_USER;
}

La transcription postée par le backend après la STT arrive comme un message texte classique préfixé par 🎙️. Traitez-le comme n'importe quel message utilisateur — aucun traitement spécial nécessaire.

12Pièces jointes du bot

Le bot enrichit ses réponses avec des attachments. Chacun est un objet attachment Stream standard avec un champ type — affichez-les selon votre design.

TypeContenuUI suggérée
image Aperçu OpenStreetMap de la position du véhicule Image inline, tap → ouvre Maps via title_link
url Lien vers Google Maps Bouton avec title + tap = ouvre title_link

Quick replies (champ custom)

Les prompts de relance arrivent dans un champ custom du message message.extraData.quick_replies. Affichez-les comme des chips sous la bulle ; au tap, envoyez la value comme nouveau message.

JSON · quick_replies dans message extra_data
{
  "quick_replies": [
    { "label": "🚀 السرعة",      "value": "قداش السرعة" },
    { "label": "📊 الحالة",        "value": "الحالة" },
    { "label": "⏱ وقتاش خرجت",    "value": "وقتاش خرجت" }
  ]
}

13Gestion des erreurs

ScénarioSymptômeSolution
Token expiré (24h) connectUser renvoie une erreur d'auth Redemander un token via ?action=token
Le canal n'existe pas 404 sur la requête de canal Appeler create() avec le bot comme membre
Le bot ne répond pas Message envoyé mais aucune réponse Vérifier l'URL du webhook backend + dispo de l'AI Engine
Upload audio trop volumineux HTTP 413 Réduire l'enregistrement à < 60s, encoder AAC mono 16kHz
Résilience réseau Le SDK Stream a un cache hors-ligne et un retry intégrés. N'ajoutez pas votre propre couche de retry — laissez-le gérer la reconnexion.