What I'm trying to do:
I am implementing the Spotify SDK into my iOS project. I am successfully receiving access tokens for Spotify's API as I am able to do things like search artists, search songs, and view playlists using said API.
The one thing I am struggling to do is play music with the SDK. I have a button that, upon clicking, I want the following flow to happen:
I request Spotify access by doing the following function and using the following Session Manager:
let SpotifyClientID = "###"
let SpotifyRedirectURL = URL(string: "bandmate://")!
lazy var configuration = SPTConfiguration(
clientID: SpotifyClientID,
redirectURL: SpotifyRedirectURL
)
lazy var sessionManager: SPTSessionManager = {
if let tokenSwapURL = URL(string: "https://bandmateallcaps.herokuapp.com/api/token"),
let tokenRefreshURL = URL(string: "https://bandmateallcaps.herokuapp.com/api/refresh_token") {
configuration.tokenSwapURL = tokenSwapURL
configuration.tokenRefreshURL = tokenRefreshURL
configuration.playURI = ""
}
let manager = SPTSessionManager(configuration: configuration, delegate: self)
return manager
}()
func requestSpotifyAccess() {
let requestedScopes: SPTScope = [.appRemoteControl, .userReadPrivate]
self.sessionManager.initiateSession(with: requestedScopes, options: .default)
}
Upon initiation of a SPTSession, I want to connect my remote:
lazy var appRemote: SPTAppRemote = {
let appRemote = SPTAppRemote(configuration: configuration, logLevel: .debug)
appRemote.delegate = self
return appRemote
}()
func sessionManager(manager: SPTSessionManager, didInitiate session: SPTSession) {
self.appRemote.connectionParameters.accessToken = session.accessToken
self.appRemote.connect()
}
Upon app connection, I want to play the ID of a Spotify track that is declared globally:
var pendingSpotifyId: String!
func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) {
print("connected")
self.appRemote.playerAPI!.delegate = self
self.appRemote.playerAPI!.subscribe(toPlayerState: { (result, error) in
if let error = error {
debugPrint(error.localizedDescription)
} else if self.pendingSpotifyId != nil {
self.appRemote.playerAPI!.play(self.pendingSpotifyId, callback: { (any, err) in
self.pendingSpotifyId = nil
})
}
})
}
My problem:
This flow is broken up as any time I try to initiate a session, sessionManager(manager: SPTSessionManager, didFailWith error: Error) is always called returning the following error:
Error Domain=com.spotify.sdk.login Code=1 "invalid_grant" UserInfo={NSLocalizedDescription=invalid_grant}
I need the session to initiate successfully so that sessionManager(manager: SPTSessionManager, didInitiate session: SPTSession) can be called and I can connect my remote and, ultimately, play my Spotify track.
What I've tried:
I have ensured a number of things:
Ensured the state of the Spotify app in the background on the user's device is playing (per this ticket: https://github.com/spotify/ios-sdk/issues/31)
Ensured that the correct scopes are in place when receiving an access token. Returned JSON looks something like:
{"access_token":"###","token_type":"Bearer","expires_in":3600,"refresh_token":"###","scope":"app-remote-control user-read-private"}
Things I'm suspicious of:
I am unaware if my token swap via Heroku is being done correctly. This is the only reason I can think of as to why I would be getting this issue. If I am able to use the Spotify API, is this evidence enough that my token swap is being done correctly? (I suspect it is)
Here's what we found out, hope it will help:
The SpotifySDK tutorial doesn't mention that Bundle ID and App Callback URL must precisely match across App Info.plist, source code, Spotify App Dashboard and Heroku Env Vars. The Bundle ID used must match your application Bundle ID.
The App Callback URL must not have empty path, ie: my-callback-scheme://spotify-login-callback
When using both Web Spotify SDK and iOS Framework Spotify SDK in app, take care that only one of them performs auth. Otherwise the App Callback URL will be called twice resulting in error.
The Spotify configuration.playURI may need to be set to empty string rather than nil. Sample app has a note on it.
It's best to have only one instance of object managing Spotify auth in the app. Otherwise ensuring that the correct object is called from the AppDelegate open url method can be tricky.
Related
I tried looking for a solution in posts such as this and this where people had a very similar problem: How to send a message from iOS App to a Safari Extension?
I even read this article where the author was explaining how to use SafariExtensionHandler to send a message from the browser to the app and back to the browser after selecting the context menu, but it's not quite what I was looking for.
Sending a Token from iOS App to Safari Extension
In the app, the user has to enter an email and password to log into their account. Once they log in, I save their information in UserDefaults like this:
class AuthDataService {
{...}
URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200,
let accessToken = httpResponse.value(forHTTPHeaderField: "Access-Token"),
let clientId = httpResponse.value(forHTTPHeaderField: "Client"),
let uid = httpResponse.value(forHTTPHeaderField: "Uid")
else {
throw CustomError.cannotExecuteRequest
}
let sharedDefaults = UserDefaults(suiteName: "group.com.MyCompany.MyProject")
sharedDefaults?.set(accessToken, forKey: "Access-Token")
sharedDefaults?.set(clientId, forKey: "Client")
sharedDefaults?.set(uid, forKey: "Uid")
return data
}
{...}
}
App-Group
From my understanding of this article, I need to create an App Group, in order to share the data between the iOS App and the Safari Extension. I named the group: "group.com.MyCompany.MyProject" (just like the suiteName in UserDefaults).
Home View
The screen that the user sees when they log in, is a SwiftUI View that has a Link which takes the user to Safari so they can open the extension themselves:
struct HomeView: View {
#EnvironmentObject var viewModel: AuthViewModel
var body: some View {
Link(destination: URL(string: "https://www.apple.com/")!) {
Text("Take me to Safari")
}
}
}
SafariWebExtensionHandler
Now, all the articles that I read were talking about how to send data from the Safari Extension to the iOS app through SafariWebExtensionHandler's beginRequest(with:).
However, I'm trying to send the Tokens in UserDefaults either whenever the user logs in the app, or when they open the Safari Extension.
I tried retrieving the data from UserDefaults to see if I could at least read it in the terminal, but the debugger never gets to the print statements:
import SafariServices
import os.log
let SFExtensionMessageKey = "message"
class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
func readData() {
let sharedDefaults = UserDefaults(suiteName: "group.com.lever.clientTokens")
print(sharedDefaults?.object(forKey: "Access-Token")) //<-- This line never gets executed
}
func beginRequest(with context: NSExtensionContext) {
let item = context.inputItems[0] as! NSExtensionItem
let message = item.userInfo?[SFExtensionMessageKey]
os_log(.default, "Received message from browser.runtime.sendNativeMessage: %#", message as! CVarArg)
let response = NSExtensionItem()
response.userInfo = [ SFExtensionMessageKey: [ "Response to": message ] ]
readData()
context.completeRequest(returningItems: [response], completionHandler: nil)
}
}
Question
macOS vs iOS
This documentation from Apple has a section called Send messages from the app to JavaScript which is pretty much what I want to do. The documentation even mentions SFSafariApplication.dispatchMessage(withName:toExtensionWithIdentifier:userInfo:completionHandler:) which in theory sends a message to the JavaScript script, but it says it only works in macOS:
You can’t send messages from a containing iOS app to your web
extension’s JavaScript scripts.
This excellent Medium article talks about sending an APIKey from the app to the Safari Extension using an API from openai.com. It seems that it also uses SFSafariApplication to communicate with SafariWebExtensionHandler, but again it looks like it only works for macOS.
Safari Extension to webPage
I also read this other Apple documentation thinking it would help, but it only talks about passing messages from the Safari Extension's popup to the webpage.
Conclusion
So my question is:
Is writing code in SafariWebExtensionHandler the right way to send data from the iOS App to my Safari Extension? Can this be done in iOS? Or is it only available for macOS?
I read some other articles that were talking about using the JavaScript files in the Resources folder in order to "listen" to changes. But I'm a little confused as to how I can send those changes from my App in order for the Safari Extension to listen to them.
What I am trying to achieve is for the user to be already logged-in in the Safari Extension after they are redirected from the HomeView in the iOS App, instead of having to sign in another time.
Thank you for your time and help!
I am trying to play songs on iOS 14 using Apple Music API.
I have the developer's token, and I have asked for the permission for accessing the user's apple music.
However, when I call requestusertoken api, its closure never gets called, so obviously I don't receive anything from the request - not even an error. It's driving me crazy.
Here is my code. What am I doing wrong?
func getUserToken() -> String {
var userToken = String()
let lock = DispatchSemaphore(value: 0)
SKCloudServiceController().requestUserToken(forDeveloperToken: developerToken) { (receivedToken, error) in
guard error == nil else { return }
if let token = receivedToken {
userToken = token
lock.signal()
}
}
lock.wait()
return userToken }
I've tried the code and there were two major problems.
First, DispatchSemaphore makes the return line execute too early. Second, original developer token doesn't work due to latest iOS 14.3 issue.
So, I first erased DispatchSemaphore.
func getUserToken() {
var userToken = String()
SKCloudServiceController().requestUserToken(forDeveloperToken: developerToken) { (receivedToken, error) in
guard error == nil else { return }
if let token = receivedToken {
userToken = token
print(userToken)
}
}
}
Then tweaked developer token following this repository.
Now, it's printing user token properly. I hope this helped.
I think we've all got stuck on the same tutorial. To fix, I put it on a different thread as the lock was holding up the main thread hence preventing the completion handler.
DispatchQueue.global(qos: .background).async {
print(AppleMusicAPI().fetchStorefrontID())
}
If it's the same tutorial, this will put the fetchStorefrontID() and the getUserToken() methods (which is called by the former) on a background thread and allow the completion handlers and the lock.signal() to occur.
If it's not, then this shall suffice for an answer:
DispatchQueue.global(qos: .background).async {
getUserToken()
}
Did you remove Bearer from your developerToken?
Okay so I know what's going on -- the DispatchSemaphore is being locked right after it's being created -- so it's never executing that code. Once I made the Semaphore value 1 instead of 0 it started to execute -- but then I had issues with the rest of the code because of the sequencing of events.
It looks like perhaps you're working with the same tutorial I was working through on Apple Music SDK integration -- if that's the case, I basically tweaked the code to :
download the user token to a local variable, and then the other methods begin to reference it in their requests.
Remove the Semaphore lock in the getuserToken() method only
The rest started working again, without having to change any other DispatchSemaphore values.
By no means am I an expert in how DispatchSemaphore works and best practices with apple music user tokens, but wanted to at least let you know why you were running into the same wall as me -- with no code being executed at all.
On iOS 13.5 with the latest AppAuth (1.4.0), I have a weird caching / universal link issue with logging in through AppAuth, logging out and logging back in again. Based on the documentation, I first discover the configuration from the server with AppAuth:
OIDAuthorizationService.discoverConfiguration(forIssuer: URL(string: "https://identityserver.example.com/")!) { ... }
Then, I build a new request:
let signinRedirectURL = URL(string: "https://portal.example.com/signin-oidc-ios")!
let request = OIDAuthorizationRequest(configuration: config,
clientId: "ios-app",
scopes: ["api"],
redirectURL: signinRedirectURL,
responseType: OIDResponseTypeCode,
additionalParameters: nil)
and present it:
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: viewController) { authState, error in self.processAuthState(authState, error) }
After logging in through the in-app browser popup, the universal link is processed:
if let authorizationFlow = appDelegate.currentAuthorizationFlow, authorizationFlow.resumeExternalUserAgentFlow(with: url) {
appDelegate.currentAuthorizationFlow = nil
} else {
print("...")
}
Finally I process the received authState:
func processAuthState(authState: OIDAuthState, error: Error) {
if let authState = authState, let token = authState.lastTokenResponse?.accessToken {
appDelegate.authState = authState
self.accessToken = token // stored later on for usage by REST API
} else {
print("Authorization error: \(error?.localizedDescription ?? "Unknown error")")
}
}
When logging out, I simply throw away the authState and currentAuthorizationFlow. Then, to log in again, the same process begins again.
The weird thing now is that AppAuth does not present a login in-app-browser popup with the login mask at https://identityserver.example.com/ as before in the first login attempt after each app launch, but instead it presents that same popup with the universal link like https://portal.example.com/signin-oidc-ios?code=abcdef&scope=api&state=xyz which was previously caught by iOS and forwarded to the app leading to the call to authorizationFlow.resumeExternalUserAgentFlow(with: url) from above.
Because we have not implemented the universal link fully yet, it leads to an error message, because the URL with the link is not supposed to be called in the browser in the moment but only to communicate the token to the app through the universal link mechanism.
Why does AppAuth or ASWebAuthenticationSession seemingly cache the last URL with an old token from the previous login attempt within the same app launch even though I throw away both the authState and currentAuthorizationFlow and create new ones? Is there something else I should do to "log out", clear the cookies etc?
I have a synchronized realm running on Realm Object Server. It is a global realm created by the admin user. I created another user on the server (not an admin) and used the admin user to grant read-only permissions to that user for that particular realm.
I can see the permission when I query the admin's management realm and the normal user's permission realm. However, when I connect to the global realm from my iOS app (Swift 3) with the normal user, the server returns error code 206: permission denied and closes the realm file.
The strange thing is that the app manages to download a few objects from the realm, and it also loads a few more objects (about 3) every time I relaunch it.
An even stranger thing is that when I launch the app with the admin user the same thing happens except it loads about 100 extra objects in every relaunch and it doesn't show the permission denied error.
Note: I'm getting these results on the iOS simulator and still haven't tried the app on an actual device.
UPDATE: I have tried the app on a physical device and the problem persists.
I'm using Realm Swift v2.10.1 on Xcode 8.3.3 and Realm Object Server v1.8.3 on a Linux Ubuntu 16.04.3 x64 machine.
EDIT:
Here is how I apply the permission:
let per = SyncPermissionValue(realmPath: "/realmname", userID: "userId", accessLevel: .read)
realmUser!.applyPermission(per, callback: { (error) in
print("PERMISSION ERROR: \(String(describing: error))")
if error == nil {
print("no error")
}
})
Here is how connect from my app. These methods are in a custom class which is used by the view controller requesting the objects from the realm.
private func logInToServer() {
SyncUser.logIn(with: .usernamePassword(username: "username", password: "pass", register: false), server: URL(string: "http://server IP:9080")!, onCompletion: { (user, error) in
var realmUser = user
if realmUser == nil {
realmUser = SyncUser.current
}
self.connectToDatabase(realmUser: realmUser!)
})
} // end logInToServer()
private func connectToDatabase(realmUser: SyncUser) {
DispatchQueue.main.async(execute: {
let configuration = Realm.Configuration(syncConfiguration: SyncConfiguration(user: realmUser, realmURL: URL(string: "realm://server IP/realmname")!))
self.realm = try! Realm(configuration: configuration)
// I initially found out that implementing a small delay gave the app enough time to download all objects even though the permission denied error was still there. But, this no longer works.
Timer.scheduledTimer(timeInterval: self.delay, target: self, selector: #selector(self.populateResults), userInfo: nil, repeats: false)
})
} // end connectToDatabase()
#objc private func populateResults() {
self.categories = self.realm.objects(BookCategory.self)
self.books = self.realm.objects(Book.self)
delegate?.didPopulteResults(categories, books) // The delegate is the view controller requesting the objects.
}
The use of invalidateAndCancel() comes up with another problem which is to reinitialize the session again if u need to use it again. After searching on this problem i got the answer of Cnoon's Alamofire Background Service, Global Manager? Global Authorisation Header?. Tried creating the manager but when it is called it never re-initialize the value, to anticipate this problem i did a small cheat i tried giving my session new name each time.
func reset() -> Manager {
let configuration: NSURLSessionConfiguration = {
let identifier = "com.footbits.theartgallery2.background-session\(sessionCount)"
let configuration = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(identifier)
return configuration
}()
return Alamofire.Manager(configuration: configuration)
}
func cancelAllRequests() {
manager.session.invalidateAndCancel()
sessionCount += 1
manager = reset()
}
Now i am curious of what is happening in background like the previous session is still there or has been removed by alamofire because if it is still there how many session can i create and what will be the after affect? because i tried:
manager.session.resetWithCompletionHandler {
print("Session reset")
}
it doesnt work.
I know this question is old, but this info will apply to you (the reader) wether or not you are using Alamofire.
You can not revalidate a URLSession, for good reason: The tasks within a session will be told to cancel upon invalidation of the session. However, those tasks may take a while to actually cancel and leave the session. To prove this, cancel a session and retrieve its tasks periodically afterwards. They'll slowly drop towards zero.
What you need to do instead is create a new session with a new identifier. Using the same identifier will cause the old session to be used and crash if new tasks are scheduled to it!
For those seeking to perform background downloads consider using a UUID().uuidString as part of the identifier and storing the id somewhere on the device (Userdefaults come to mind), and generating a new uuid after every call to invalidate your current session. Then create a new session object with a new uuid and reference that instead. Bonus: This new session immediately will have 0 open tasks which proves useful if your logic is based on that.
Example from within my project:
#UserDefaultOptional(defaultValue: nil, "com.com.company.sessionid")
private var sessionID: String?
// MARK: - Init
private var session: URLSession!
private override init() {
super.init()
setupNewSession()
}
private func setupNewSession() {
let id = sessionID ?? UUID().uuidString
sessionID = id
let configuration = URLSessionConfiguration.background(withIdentifier: id)
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
}
private func invalidateSession() {
session.invalidateAndCancel()
sessionID = nil
setupNewSession()
}