PROBLEM SUMMARY
My goal is to use an iOS app written in SwiftUI to connect with AgoraRtcEngineKit. I want to create an app that is audio-only and allows a host to broadcast audio and allows listeners to listen in.
The use of tokens is required by Agora.
I created an Agora Token Server using Node.js based on Agora's tutorial found here: https://www.agora.io/en/blog/how-to-build-a-token-server-for-agora-applications-using-nodejs/
Here is my index.js from my Agora-Node-TokenServer. This code is based on the Agora tutorial found here: https://github.com/digitallysavvy/Agora-Node-TokenServer/blob/master/index.js
const express = require('express')
const path = require('path')
const {RtcTokenBuilder, RtcRole} = require('agora-access-token');
const PORT = process.env.PORT || 5000
if (!(process.env.APP_ID && process.env.APP_CERTIFICATE)) {
throw new Error('You must define an APP_ID and APP_CERTIFICATE');
}
const APP_ID = process.env.APP_ID;
const APP_CERTIFICATE = process.env.APP_CERTIFICATE;
const app = express();
const nocache = (req, resp, next) => {
resp.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
resp.header('Expires', '-1');
resp.header('Pragma', 'no-cache');
next();
};
const generateAccessToken = (req, resp) => {
resp.header('Access-Control-Allow-Origin', '*');
const channelName = req.query.channelName;if (!channelName) {
return resp.status(500).json({ 'error': 'channel is required' });
}
// get uid
let uid = req.query.uid;
if(!uid || uid == '') {
uid = 0;
}
// get rtc role
let rtcrole = RtcRole.SUBSCRIBER;
if (req.query.rtcrole == 'publisher') {
rtcrole = RtcRole.PUBLISHER;
}
// get the expire time
let expireTime = req.query.expireTime;
if (!expireTime || expireTime == '') {
expireTime = 3600;
} else {
expireTime = parseInt(expireTime, 10);
}
// calculate privilege expire time
const currentTime = Math.floor(Date.now() / 1000);
const privilegeExpireTime = currentTime expireTime;
const rtctoken = RtcTokenBuilder.buildTokenWithUid(APP_ID, APP_CERTIFICATE, channelName, uid, rtcrole, privilegeExpireTime);
return resp.json({ 'rtctoken': rtctoken });
};
app.get('/access_token', nocache, generateAccessToken);
app.listen(PORT, () => {
console.log(`Listening on port: ${PORT}`);
});
What I expected What I expected was to be able to successfully authenticate my iOS app with Agora.
Actual results I am unable to authenticate with Agora.
- My Node.js token server successfully delivers a token to my iOS app.
- But when I submit said token to Agora using rtckit.joinChannel(byToken:...) nothing happens. The joinChannel's completion block is never reached.
- As an experiment I submitted the 'temporary token' from Agora's console to Agora using rtckit.joinChannel(byToken:...) and this token was accepted successfully and the completion block was reached.
What I have tried
My iOS app is able to connect with Agora by using Temporary Tokens created using Agora's console. Agora's console allows developers to create Temporary Tokens to test their apps. Since my app is able to authenticate using these temporary tokens it suggest to me the problem lies somewhere with the NodeJS token server I created?
I have reviewed similar questions on Stack Overflow like:
- how to generate token for agora RTC for live stream and join channel
- Made sure my APP_ID and APP_CERTIFICATE matched those in Agora's console as recommended here: Agora Video Calling Android get error code 101
For reference here is the relevant code from my iOS app below. I based this code on the Agora Tutorial found here: https://github.com/maxxfrazer/Agora-iOS-Swift-Example/blob/main/Agora-iOS-Example/AgoraToken.swift and here: https://www.agora.io/en/blog/creating-live-audio-chat-rooms-with-swiftui/
AgoraToken
import Foundation
class AgoraToken {
/// Error types to expect from fetchToken on failing ot retrieve valid token.
enum TokenError: Error {
case noData
case invalidData
case invalidURL
}
/// Requests the token from our backend token service
/// - Parameter urlBase: base URL specifying where the token server is located
/// - Parameter channelName: Name of the channel we're requesting for
/// - Parameter uid: User ID of the user trying to join (0 for any user)
/// - Parameter callback: Callback method for returning either the string token or error
static func fetchToken(
urlBase: String, channelName: String, uid: UInt,
callback: @escaping (Result<String, Error>) -> Void
) {
guard let fullURL = URL(string: "\(urlBase)/?channelName=\(channelName)/&uid=\(uid)/") else {
callback(.failure(TokenError.invalidURL))
return
}
print("fullURL yields \(fullURL)")
var request = URLRequest(
url: fullURL,
timeoutInterval: 10
)
request.httpMethod = "GET"
let task = URLSession.shared.dataTask(with: request) { data, response, err in
print("Within URLSession.shared.dataTask response is \(String(describing: response)) and err is \(String(describing: err))")
if let dataExists = data {
print(String(bytes: dataExists, encoding: String.Encoding.utf8) ?? "URLSession no data exists")
}
guard let data = data else {
if let err = err {
callback(.failure(err))
} else {
callback(.failure(TokenError.noData))
}
return
}
let responseJSON = try? JSONSerialization.jsonObject(with: data, options: [])
if let responseDict = responseJSON as? [String: Any], let rtctoken = responseDict["rtctoken"] as? String {
print("rtc token is \(rtctoken)")
callback(.success(rtctoken))
} else {
callback(.failure(TokenError.invalidData))
}
}
task.resume()
}
}
Podfile
# Uncomment the next line to define a global platform for your project
platform :ios, '14.8.1'
target 'AgoraPractice' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for AgoraPractice
pod 'AgoraRtm_iOS'
pod 'AgoraAudio_iOS'
target 'AgoraPracticeTests' do
inherit! :search_paths
# Pods for testing
end
target 'AgoraPracticeUITests' do
# Pods for testing
end
end
ContentView
import SwiftUI
import CoreData
import AgoraRtcKit
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@State var joinedChannel: Bool = false
@ObservedObject var agoraObservable = AgoraObservable()
var body: some View {
Form {
Section(header: Text("Channel Information")) {
TextField(
"Channel Name", text: $agoraObservable.channelName
).disabled(joinedChannel)
TextField(
"Username", text: $agoraObservable.username
).disabled(joinedChannel)
}
Button(action: {
joinedChannel.toggle()
if !joinedChannel {
self.agoraObservable.leaveChannel()
} else {
self.agoraObservable.checkIfChannelTokenExists()
}
}, label: {
Text("\(joinedChannel ? "Leave" : "Join") Channel")
.accentColor(joinedChannel ? .red : .blue)
})
if joinedChannel {
Button(action: {
agoraObservable.rtckit.setClientRole(.audience)
}, label: {
Text("Become audience member")
})
Button(action: {
agoraObservable.rtckit.setClientRole(.broadcaster)
}, label: {
Text("Become broadcasting member")
})
}
}
}
}
struct UserData: Codable {
var rtcId: UInt
var username: String
func toJSONString() throws -> String? {
let jsonData = try JSONEncoder().encode(self)
return String(data: jsonData, encoding: .utf8)
}
}
class AgoraObservable: NSObject, ObservableObject {
@Published var remoteUserIDs: Set<UInt> = []
@Published var channelName: String = ""
@Published var username: String = ""
// Temp Token and Channel Name
var tempToken:String = "XXXXXXX"
var tempChannelName:String = "XXXXX"
var rtcId: UInt = 0
//var rtcId: UInt = 1264211369
let tokenBaseURL:String = Secrets.baseUrl
// channelToken is rtctoken. I should change this variable name at some point to rtctoken...
var channelToken:String = ""
lazy var rtckit: AgoraRtcEngineKit = {
let engine = AgoraRtcEngineKit.sharedEngine(
withAppId: Secrets.agoraAppId, delegate: self
)
engine.setChannelProfile(.liveBroadcasting)
engine.setClientRole(.audience)
return engine
}()
}
extension AgoraObservable {
func checkIfChannelTokenExists() {
if channelToken.isEmpty {
joinChannelWithFetch()
} else {
joinChannel()
}
}
func joinChannelWithFetch() {
AgoraToken.fetchToken(
urlBase: tokenBaseURL,
channelName: self.channelName,
uid: self.rtcId
) { result in
switch result {
case .success(let tokenExists):
self.channelToken = tokenExists
print("func joinChannelWithFetch(): channelToken = \(self.channelToken) and rtcuid = \(self.rtcId)")
self.joinChannel()
case .failure(let err):
print(err)
// To Do: Handle this error with an alert
self.leaveChannel()
}
}
}
func joinChannel(){
print("func joinChannel(): channelToken = \(self.channelToken) and channelName = \(self.channelName) and rtcuid = \(self.rtcId)")
self.rtckit.joinChannel(byToken: self.channelToken, channelId: self.channelName, info: nil, uid: self.rtcId) { [weak self] (channel, uid, errCode) in
print("within rtckit.joinchannel yields: channel:\(channel) and uid:\(uid) and error:\(errCode)")
self?.rtcId = uid
}
// I need to error handle if user cannot loginto rtckit
}
func updateToken(_ newToken:String){
channelToken = newToken
self.rtckit.renewToken(newToken)
print("Updating token now...")
}
func leaveChannel() {
self.rtckit.leaveChannel()
}
}
extension AgoraObservable: AgoraRtcEngineDelegate {
/// Called when the user role successfully changes
/// - Parameters:
/// - engine: AgoraRtcEngine of this session.
/// - oldRole: Previous role of the user.
/// - newRole: New role of the user.
func rtcEngine(
_ engine: AgoraRtcEngineKit,
didClientRoleChanged oldRole: AgoraClientRole,
newRole: AgoraClientRole
) {
print("AgoraRtcEngineDelegate didClientRoleChanged triggered...old role: \(oldRole), new role: \(newRole)")
}
func rtcEngine(
_ engine: AgoraRtcEngineKit,
didJoinedOfUid uid: UInt,
elapsed: Int
) {
// Keeping track of all people in the session
print("rtcEngine didJoinedOfUid triggered...")
remoteUserIDs.insert(uid)
}
func rtcEngine(
_ engine: AgoraRtcEngineKit,
didOfflineOfUid uid: UInt,
reason: AgoraUserOfflineReason
) {
print("rtcEngine didOfflineOfUid triggered...")
// Removing on quit and dropped only
// the other option is `.becomeAudience`,
// which means it's still relevant.
if reason == .quit || reason == .dropped {
remoteUserIDs.remove(uid)
} else {
// User is no longer hosting, need to change the lookups
// and remove this view from the list
// userVideoLookup.removeValue(forKey: uid)
}
}
func rtcEngine(
_ engine: AgoraRtcEngineKit,
tokenPrivilegeWillExpire token: String
) {
print("tokenPrivilegeWillExpire delegate method called...")
AgoraToken.fetchToken(
urlBase: tokenBaseURL, channelName: self.channelName, uid: self.rtcId) { result in
switch result {
case .failure(let err):
fatalError("Could not refresh token: \(err)")
case .success(let newToken):
print("token successfully updated")
self.updateToken(newToken)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
CodePudding user response:
It turns out I made a mistake when I was trying to define the 'fullURL' for my http request to fetch a token. I am new to http requests so I did not know I had made an error.
In my AgoraToken.swift file, my erroneous fullURL definition was:
guard let fullURL = URL(string: "\(urlBase)/?channelName=\(channelName)/&uid=\(uid)/") else {...
As you will be able to see if you aren't a noob like me with this code channelName and uid will both be sent to my Token Server with trailing slashes. So if my channelName was 'TupperwareParty' my Token Server would get 'TupperwareParty/' and if my uid was '123456' my Token Server would get '123456/'.
I fixed it with this new fullURL definition...
guard let fullURL = URL(string: "\(urlBase)/?channelName=\(channelName)&uid=\(uid)") else {...
Sigh...