Java tutorial
/* Pandoroid Radio - open source pandora.com client for android * Copyright (C) 2011 Andrew Regner <andrew@aregner.com> * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package com.pandoroid.pandora; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Vector; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; import org.apache.http.client.HttpResponseException; import org.json.JSONArray; import org.json.JSONObject; import android.util.Log; /** * Description: Uses Pandora's JSON v5 API. Documentation of the JSON API * can be found here: http://pan-do-ra-api.wikia.com/wiki/Json/5 * A network connection is required before any operation can/should take place. * If a person is bored, and wants to work on something, they could split * off the authentication component from this class while making both this * and the authentication component inherit from the RPC class * (and also beef up the RPC class a bit). It would also be helpful * to give this class a more meaningful name like PandoraAuthenticationRPC/ * PandoraPlaylistRPC/PandoraRPC.... */ public class PandoraRadio { private static final String USER_AGENT = "com.pandoroid.pandora/0.4"; /* * JSON API stuff */ public static final String PROTOCOL_VERSION = "5"; //PandoraOne specific credentials private static final String ONE_RPC_URL = "internal-tuner.pandora.com/services/json/"; private static final String ONE_DEVICE_ID = "D01"; private static final String ONE_PARTNER_USERNAME = "pandora one"; private static final String ONE_PARTNER_PASSWORD = "TVCKIBGS9AO9TSYLNNFUML0743LH82D"; private static final String ONE_DECRYPT_CIPHER = "U#IO$RZPAB%VX2"; private static final String ONE_ENCRYPT_CIPHER = "2%3WCL*JU$MP]4"; //Android standard user specific credentials private static final String AND_RPC_URL = "tuner.pandora.com/services/json/"; private static final String AND_DEVICE_ID = "android-generic"; private static final String AND_PARTNER_USERNAME = "android"; private static final String AND_PARTNER_PASSWORD = "AC7IBG09A3DTSYM4R41UJWL07VLN8JI7"; private static final String AND_DECRYPT_CIPHER = "R=U!LH$O2B#"; private static final String AND_ENCRYPT_CIPHER = "6#26FRL$ZWD"; private static final String MIME_TYPE = "text/plain"; //This probably isn't important /* END API */ public static final long PLAYLIST_VALIDITY_TIME = 3600 * 3; public static final String DEFAULT_AUDIO_FORMAT = "aacplus"; public static final long MIN_TIME_BETWEEN_PLAYLIST_CALLS = 30; //seconds //Audio quality strings public static final String AAC_32 = "HTTP_32_AACPLUS"; public static final String AAC_64 = "HTTP_64_AACPLUS"; public static final String MP3_128 = "HTTP_128_MP3"; public static final String MP3_192 = "HTTP_192_MP3"; //End Audio private RPC pandora_rpc; private PartnerCredentials credentials; private Cipher blowfish_encode; private Cipher blowfish_decode; private String user_auth_token; private String partner_auth_token; private long sync_time; private long sync_obtained_time; private long last_acquired_playlist_time; private String last_acquired_playlist_station; private Map<String, String> standard_url_params; /** * */ public PandoraRadio() { standard_url_params = new HashMap<String, String>(); credentials = new PartnerCredentials(); last_acquired_playlist_time = 0; last_acquired_playlist_station = new String(); } /** * Description: Compares two constant audio quality strings as defined in * PandoraRadio. * @param value -The value making the comparison. * @param relative_to -The value being compared to. * @return An integer that's positive when value is greater than relative_to, * negative when value is less than relative_to, and 0 when they're * equivalent. * @throws Exception when the strings are invalid to be making comparisons * against (One or both is not one of the defined constants). */ public static int audioQualityCompare(String value, String relative_to) throws Exception { int str1_magnitude = getRelativeAudioQualityMagnitude(value); int str2_magnitude = getRelativeAudioQualityMagnitude(relative_to); if (str1_magnitude != -1 && str2_magnitude != -1) { return (str1_magnitude - str2_magnitude); } else { throw new Exception("Invalid strings to compare"); } } /** * Description: Disabled */ public void bookmarkArtist(Station station, Song song) { //Vector<Object> args = new Vector<Object>(1); //args.add(song.getArtistMusicId()); //doCall("station.createArtistBookmark", args, null); } /** * Description: Disabled */ public void bookmarkSong(Station station, Song song) { // Vector<Object> args = new Vector<Object>(2); // args.add(String.valueOf(station.getId())); // args.add(song.getId()); //doCall("station.createBookmark", args, null); } /** * Description: Keeps track of the remote server's sync time. * @return An integer for the current sync time. */ private int calcSync() { return (int) (sync_time + ((System.currentTimeMillis() / 1000L) - this.sync_obtained_time)); } /** * Description: Logs a user in. * @param user -The user's username. * @param password -The user's password. * @throws RPCException when a Pandora RPC error occurs. * @throws SubscriberTypeException when a Pandora user is identified as not * being the expected subscriber type. * @throws IOException when Pandora's servers can't be reached. * @throws HttpResponseException when an unexpected HTTP response occurs. * @throws Exception when an improper call to connect has been made. */ public void connect(String user, String password) throws RPCException, SubscriberTypeException, IOException, HttpResponseException, Exception { if (!this.isPartnerAuthorized()) { throw new Exception("Improper call to connect(), " + "the application is not authorized."); } Map<String, Object> request_args = new HashMap<String, Object>(); request_args.put("loginType", "user"); request_args.put("username", user); request_args.put("password", password); request_args.put("partnerAuthToken", this.partner_auth_token); JSONObject result = this.doCall("auth.userLogin", request_args, true, true, null); //There are a few ways of handling these, but this way seems most //appropriate. //If this is a PandoraOne subscriber and the credentials aren't correct if (!result.getBoolean("hasAudioAds") && !isPandoraOneCredentials()) { throw new SubscriberTypeException(true, "The subscriber is Pandora One and default device credentials were given."); // this.runPartnerLogin(true); } //If this is a non-PandoraOne subscriber and the credentials aren't correct else if (result.getBoolean("hasAudioAds") && isPandoraOneCredentials()) { throw new SubscriberTypeException(false, "The subscriber is standard and Pandora One device credentials were given."); // this.runPartnerLogin(false); } else { this.user_auth_token = result.getString("userAuthToken"); this.standard_url_params.put("auth_token", user_auth_token); this.standard_url_params.put("user_id", result.getString("userId")); //return user_auth_token != null; } } /** * Effectively logs a user off. */ public void disconnect() { this.standard_url_params.remove("user_id"); this.standard_url_params.put("auth_token", this.partner_auth_token); user_auth_token = null; last_acquired_playlist_station = ""; last_acquired_playlist_time = 0; } /** * Description: Here we are making our remote procedure call specifically * using Pandora's JSON protocol. This will return a JSONObject holding * the contents of the results key in the response. If an error occurs * (ie "stat":"fail") an exception with the message body will be thrown. * Caution: When debugging, be sure to note that most data that flows * through here is time sensitive, and if stopped in the wrong places, * it will cause "stat":"fail" responses from the remote server. */ private JSONObject doCall(String method, Map<String, Object> json_params, boolean http_secure_flag, boolean encrypt, Map<String, String> opt_url_params) throws Exception, RPCException, IOException, HttpResponseException { JSONObject response = null; JSONObject request = null; if (json_params != null) { request = new JSONObject(json_params); } else { request = new JSONObject(); } Map<String, String> url_params = new HashMap<String, String>(standard_url_params); url_params.put("method", method); if (opt_url_params != null) { url_params.putAll(opt_url_params); } if (user_auth_token != null) { request.put("userAuthToken", user_auth_token); } if (sync_time != 0) { request.put("syncTime", calcSync()); } String request_string = request.toString(); if (encrypt) { request_string = this.pandoraEncrypt(request_string); } String response_string = pandora_rpc.call(url_params, request_string, http_secure_flag); response = new JSONObject(response_string); if (response.getString("stat").compareTo("ok") != 0) { if (response.getString("stat").compareTo("fail") == 0) { throw new RPCException(response.getInt("code"), response.getString("message")); } else { throw new Exception("RPC unknown error. stat: " + response.getString("stat")); } } return response.getJSONObject("result"); //Exception thrown if nonexistent } /** * Description: Gets a list of songs to be played. This function should not * be called more frequently than MIN_TIME_BETWEEN_PLAYLIST_CALLS allows * or an error will result. * @param station_token -A string representing the station's unique * identification token. * @throws RPCException when a Pandora RPC error has occurred. * @throws IOException when Pandora's remote servers could not be reached. * @throws HttpResponseException when an unexpected HTTP response occurs. * @throws Exception when an improper call has been made, or an unexpected * error occurs. */ @SuppressWarnings("unchecked") public Vector<Song> getPlaylist(String station_token) throws RPCException, IOException, HttpResponseException, Exception { //This protects against a temporary account suspension from too many //playlist requests. if (!isGetPlaylistCallValid(station_token)) { throw new Exception("Playlist calls are too frequent"); } if (!this.isUserAuthorized()) { throw new Exception("Improper call to getPlaylist(), " + "the user has not been logged in yet."); } Vector<Song> songs = new Vector<Song>(); Map<String, Object> request_args = new HashMap<String, Object>(); request_args.put("stationToken", station_token); //Order matters in this URL request. The same order given here is //the order received. request_args.put("additionalAudioUrl", MP3_128 + "," + AAC_32); JSONObject response = this.doCall("station.getPlaylist", request_args, true, true, null); JSONArray songs_returned = response.getJSONArray("items"); for (int i = 0; i < songs_returned.length(); ++i) { Map<String, Object> song_data = JSONHelper.toMap(songs_returned.getJSONObject(i)); ArrayList<PandoraAudioUrl> audio_url_mappings = new ArrayList<PandoraAudioUrl>(); if (song_data.get("additionalAudioUrl") instanceof Vector<?>) { Vector<String> audio_urls = (Vector<String>) song_data.get("additionalAudioUrl"); //This has to be in the same order as the request. audio_url_mappings.add(new PandoraAudioUrl(MP3_128, 128, audio_urls.get(0))); audio_url_mappings.add(new PandoraAudioUrl(AAC_32, 32, audio_urls.get(1))); } //MP3_192 data if (isPandoraOneCredentials()) { audio_url_mappings.add(new PandoraAudioUrl( (Map<String, Object>) ((Map<String, Object>) song_data.get("audioUrlMap")) .get("highQuality"))); } //AAC_64 data audio_url_mappings.add( new PandoraAudioUrl((Map<String, Object>) ((Map<String, Object>) song_data.get("audioUrlMap")) .get("mediumQuality"))); songs.add(new Song(song_data, audio_url_mappings)); } this.last_acquired_playlist_time = System.currentTimeMillis() / 1000L; this.last_acquired_playlist_station = station_token; return songs; } public static int getRelativeAudioQualityMagnitude(String quality_string) { if (quality_string.compareTo(MP3_192) == 0) { return 4; } if (quality_string.compareTo(MP3_128) == 0) { return 3; } if (quality_string.compareTo(AAC_64) == 0) { return 2; } if (quality_string.compareTo(AAC_32) == 0) { return 1; } return -1; } /** * Description: Retrieves the available stations, saves them to a * PandoraRadio member variable, and returns them. */ public ArrayList<Station> getStations() throws RPCException, IOException, HttpResponseException, Exception { if (!this.isUserAuthorized()) { throw new Exception("Improper call to getStations(), " + "the user has not been logged in yet."); } JSONObject result = doCall("user.getStationList", null, false, true, null); //Our stations come in a JSONArray within the JSONObject JSONArray result_stations = result.getJSONArray("stations"); ArrayList<Station> stations = new ArrayList<Station>(); //Run through the stations within the array, and pick out some of the //properties we want. for (int i = 0; i < result_stations.length(); ++i) { JSONObject single_station = result_stations.getJSONObject(i); HashMap<String, Object> station_prep = new HashMap<String, Object>(); station_prep.put("stationId", single_station.get("stationId")); station_prep.put("stationIdToken", single_station.get("stationToken")); station_prep.put("stationName", single_station.get("stationName")); station_prep.put("isQuickMix", single_station.get("isQuickMix")); stations.add(new Station(station_prep, this)); } return stations; } /** * Description: Self explanatory function that converts from a hex string * to a plain string. One complicated portion of this to mention is that * String types can do something rather odd when conversions are made * from byte arrays to Strings and back again. They don't like to work * out perfectly. */ private byte[] fromHex(String hex_text) { int hex_len = hex_text.length(); byte[] raw = new byte[hex_len / 2]; for (int i = 0; i < hex_len; i += 2) { raw[i / 2] = (byte) ((Character.digit(hex_text.charAt(i), 16) * 16) + Character.digit(hex_text.charAt(i + 1), 16)); } return raw; } /** * Description: I had to look far and wide to find this implementation. * Java's builtin Integer.toHexString() function is absolutely atrocious. * A person can't depend on it for any kind of formatting predictability. * For speed's sake, having the HEX_CHARS constant is a necessity. */ private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray(); private String toHex(byte[] clean_text) { char[] chars = new char[2 * clean_text.length]; for (int i = 0; i < clean_text.length; ++i) { chars[2 * i] = HEX_CHARS[(clean_text[i] & 0xF0) >>> 4]; chars[2 * i + 1] = HEX_CHARS[clean_text[i] & 0x0F]; } return new String(chars); } public boolean isAlive() { return isUserAuthorized(); } private boolean isGetPlaylistCallValid(String station_token) { if ((station_token.compareTo(last_acquired_playlist_station) == 0) && (this.last_acquired_playlist_time > ((System.currentTimeMillis() / 1000L) - MIN_TIME_BETWEEN_PLAYLIST_CALLS))) { return false; } return true; } public boolean isPandoraOneCredentials() { return (credentials.device_model == ONE_DEVICE_ID); } public boolean isPartnerAuthorized() { return (this.partner_auth_token != null); } public boolean isUserAuthorized() { return (this.user_auth_token != null); } /** * Description: This takes a Blowfish encrypted, hexadecimal string, and * decrypts it to a plain string form. */ public String pandoraDecrypt(String s) throws GeneralSecurityException, BadPaddingException { byte[] bytes = fromHex(s); byte[] raw_decoded = blowfish_decode.doFinal(bytes); String result_string = new String(raw_decoded); return result_string; } /** * Description: This function encrypts a string using a Blowfish cipher, * and returns the hexadecimal representation of the encryption. */ public String pandoraEncrypt(String s) throws GeneralSecurityException, BadPaddingException { byte[] byte_s = s.getBytes(); if (byte_s.length % 8 != 0) { int padding_size = 8 - (byte_s.length % 8); byte[] tmp_bytes = new byte[byte_s.length + padding_size]; System.arraycopy(byte_s, 0, tmp_bytes, 0, byte_s.length); byte_s = new byte[tmp_bytes.length]; System.arraycopy(tmp_bytes, 0, byte_s, 0, tmp_bytes.length); } byte[] encode_raw = blowfish_encode.doFinal(byte_s); String result_string = new String(encode_raw); result_string = toHex(encode_raw); return result_string; } /** * Description: This is the authorization for the app itself. */ private void partnerLogin() throws RPCException, IOException, HttpResponseException, Exception { Map<String, Object> partner_params = new HashMap<String, Object>(4); partner_params.put("username", credentials.username); partner_params.put("password", credentials.password); partner_params.put("deviceModel", credentials.device_model); partner_params.put("version", PROTOCOL_VERSION); JSONObject partner_return = null; partner_return = this.doCall("auth.partnerLogin", partner_params, true, false, null); partner_auth_token = partner_return.getString("partnerAuthToken"); standard_url_params.put("partner_id", partner_return.getString("partnerId")); setSync(partner_return.getString("syncTime")); standard_url_params.put("auth_token", partner_auth_token); } /** * Description: Sends a song rating to the remote server. */ public void rate(Song song, boolean rating) throws RPCException, IOException, HttpResponseException, Exception { Map<String, Object> feedback_params = new HashMap<String, Object>(2); feedback_params.put("trackToken", song.getId()); feedback_params.put("isPositive", rating); this.doCall("station.addFeedback", feedback_params, false, true, null); } /** * Description: This will run a partner login with the proper partner * credentials as specified by the is_pandora_one variable. */ public void runPartnerLogin(boolean is_pandora_one) throws RPCException, IOException, HttpResponseException, Exception { setCredentials(is_pandora_one); this.partnerLogin(); } /** * Description: Sets the cipher keys up. * @throws Exception */ private void setCipher() throws NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException { //We're using the built in Blowfish cipher here. blowfish_encode = Cipher.getInstance("Blowfish/ECB/NoPadding"); byte[] encrypt_key_data = this.credentials.e_cipher.getBytes(); SecretKeySpec key_spec = new SecretKeySpec(encrypt_key_data, "Blowfish"); blowfish_encode.init(Cipher.ENCRYPT_MODE, key_spec); blowfish_decode = Cipher.getInstance("Blowfish/ECB/NoPadding"); byte[] decrypt_key_data = this.credentials.d_cipher.getBytes(); key_spec = new SecretKeySpec(decrypt_key_data, "Blowfish"); blowfish_decode.init(Cipher.DECRYPT_MODE, key_spec); } /** * Description: This sets or resets (depending on how you look at it) the * credentials for the app's authentication. * Postcondition: partnerLogin() will need to be called. * @param is_pandora_one * @throws Exception */ private void setCredentials(boolean is_pandora_one) throws Exception { if (is_pandora_one) { credentials.rpc_url = ONE_RPC_URL; credentials.device_model = ONE_DEVICE_ID; credentials.username = ONE_PARTNER_USERNAME; credentials.password = ONE_PARTNER_PASSWORD; credentials.d_cipher = ONE_DECRYPT_CIPHER; credentials.e_cipher = ONE_ENCRYPT_CIPHER; } else { credentials.rpc_url = AND_RPC_URL; credentials.device_model = AND_DEVICE_ID; credentials.username = AND_PARTNER_USERNAME; credentials.password = AND_PARTNER_PASSWORD; credentials.d_cipher = AND_DECRYPT_CIPHER; credentials.e_cipher = AND_ENCRYPT_CIPHER; } this.sync_time = 0; this.pandora_rpc = new RPC(this.credentials.rpc_url, MIME_TYPE, USER_AGENT); try { this.setCipher(); } catch (Exception e) { Log.e("Pandoroid", "Fatal error in cipher", e); throw new Exception("Cipher error", e); } } /** * Description: The sync time from the remote server is rather special (or maybe not). * It comes in hexadecimal form from which it must be dehexed to byte form, * then it must be decrypted with the Blowfish decryption. From there, * it's hidden inside a string with 4 bytes of junk characters at the * beginning, and two white space characters at the end. * Unfortunately Java is screwing with me in obtaining this time. */ private void setSync(String encoded_sync_time) throws Exception { this.sync_obtained_time = System.currentTimeMillis() / 1000L; // //This time stamp contains a lot of junk in the string it's in. // String junk = pandoraDecrypt(encoded_sync_time); //First decrypt the hex // // //There is a problem with this algorithm and I suspect it's happening here // //in regards to the junk.substring() function. // junk = junk.substring(4); //Remove the first 4 bytes of junk. // junk = junk.trim(); //Trim off the predictable white space chunks at the end. // this.sync_time = Long.parseLong(junk); //As long as our system clocks are accurate, using the system clock //is a suitable (and potentially long term) solution to this issue. this.sync_time = this.sync_obtained_time; } /** * Description: Disabled */ public void tired(Station station, Song song) { // Vector<Object> args = new Vector<Object>(3); // args.add(song.getId()); // //args.add(song.getUserSeed()); // args.add(String.valueOf(station.getId())); // // //doCall("listener.addTiredSong", args, null); } }