Xmpp with Java Asmack Library Supporting X-Facebook-Platform

XMPP with Java Asmack library supporting X-FACEBOOK-PLATFORM

Finally, thanks to the no.good.at.coding code and the suggestion of harism, I've been able to connect to the Facebook chat. This code is the Mechanism for the Asmack library (the Smack port for Android). For the Smack library is necessary to use the no.good.at.coding mechanism.

SASLXFacebookPlatformMechanism.java:

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Map;

import org.apache.harmony.javax.security.auth.callback.CallbackHandler;
import org.apache.harmony.javax.security.sasl.Sasl;
import org.jivesoftware.smack.SASLAuthentication;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.sasl.SASLMechanism;
import org.jivesoftware.smack.util.Base64;

public class SASLXFacebookPlatformMechanism extends SASLMechanism
{

private static final String NAME = "X-FACEBOOK-PLATFORM";

private String apiKey = "";
private String applicationSecret = "";
private String sessionKey = "";

/**
* Constructor.
*/
public SASLXFacebookPlatformMechanism(SASLAuthentication saslAuthentication)
{
super(saslAuthentication);
}

@Override
protected void authenticate() throws IOException, XMPPException
{

getSASLAuthentication().send(new AuthMechanism(NAME, ""));
}

@Override
public void authenticate(String apiKeyAndSessionKey, String host,
String applicationSecret) throws IOException, XMPPException
{
if (apiKeyAndSessionKey == null || applicationSecret == null)
{
throw new IllegalArgumentException("Invalid parameters");
}

String[] keyArray = apiKeyAndSessionKey.split("\\|", 2);
if (keyArray.length < 2)
{
throw new IllegalArgumentException(
"API key or session key is not present");
}

this.apiKey = keyArray[0];
this.applicationSecret = applicationSecret;
this.sessionKey = keyArray[1];

this.authenticationId = sessionKey;
this.password = applicationSecret;
this.hostname = host;

String[] mechanisms = { "DIGEST-MD5" };
Map<String, String> props = new HashMap<String, String>();
this.sc =
Sasl.createSaslClient(mechanisms, null, "xmpp", host, props,
this);
authenticate();
}

@Override
public void authenticate(String username, String host, CallbackHandler cbh)
throws IOException, XMPPException
{
String[] mechanisms = { "DIGEST-MD5" };
Map<String, String> props = new HashMap<String, String>();
this.sc =
Sasl.createSaslClient(mechanisms, null, "xmpp", host, props,
cbh);
authenticate();
}

@Override
protected String getName()
{
return NAME;
}

@Override
public void challengeReceived(String challenge) throws IOException
{
byte[] response = null;

if (challenge != null)
{
String decodedChallenge = new String(Base64.decode(challenge));
Map<String, String> parameters = getQueryMap(decodedChallenge);

String version = "1.0";
String nonce = parameters.get("nonce");
String method = parameters.get("method");

long callId = new GregorianCalendar().getTimeInMillis();

String sig =
"api_key=" + apiKey + "call_id=" + callId + "method="
+ method + "nonce=" + nonce + "session_key="
+ sessionKey + "v=" + version + applicationSecret;

try
{
sig = md5(sig);
} catch (NoSuchAlgorithmException e)
{
throw new IllegalStateException(e);
}

String composedResponse =
"api_key=" + URLEncoder.encode(apiKey, "utf-8")
+ "&call_id=" + callId + "&method="
+ URLEncoder.encode(method, "utf-8") + "&nonce="
+ URLEncoder.encode(nonce, "utf-8")
+ "&session_key="
+ URLEncoder.encode(sessionKey, "utf-8") + "&v="
+ URLEncoder.encode(version, "utf-8") + "&sig="
+ URLEncoder.encode(sig, "utf-8");

response = composedResponse.getBytes("utf-8");
}

String authenticationText = "";

if (response != null)
{
authenticationText =
Base64.encodeBytes(response, Base64.DONT_BREAK_LINES);
}

// Send the authentication to the server
getSASLAuthentication().send(new Response(authenticationText));
}

private Map<String, String> getQueryMap(String query)
{
Map<String, String> map = new HashMap<String, String>();
String[] params = query.split("\\&");

for (String param : params)
{
String[] fields = param.split("=", 2);
map.put(fields[0], (fields.length > 1 ? fields[1] : null));
}

return map;
}

private String md5(String text) throws NoSuchAlgorithmException,
UnsupportedEncodingException
{
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(text.getBytes("utf-8"), 0, text.length());
return convertToHex(md.digest());
}

private String convertToHex(byte[] data)
{
StringBuilder buf = new StringBuilder();
int len = data.length;

for (int i = 0; i < len; i++)
{
int halfByte = (data[i] >>> 4) & 0xF;
int twoHalfs = 0;

do
{
if (0 <= halfByte && halfByte <= 9)
{
buf.append((char) ('0' + halfByte));
}
else
{
buf.append((char) ('a' + halfByte - 10));
}
halfByte = data[i] & 0xF;
} while (twoHalfs++ < 1);
}

return buf.toString();
}
}

To use it:

ConnectionConfiguration config = new ConnectionConfiguration("chat.facebook.com", 5222);
config.setSASLAuthenticationEnabled(true);
XMPPConnection xmpp = new XMPPConnection(config);
try
{
SASLAuthentication.registerSASLMechanism("X-FACEBOOK-PLATFORM", SASLXFacebookPlatformMechanism.class);
SASLAuthentication.supportSASLMechanism("X-FACEBOOK-PLATFORM", 0);
xmpp.connect();
xmpp.login(apiKey + "|" + sessionKey, sessionSecret, "Application");
} catch (XMPPException e)
{
xmpp.disconnect();
e.printStackTrace();
}

apiKey is the API key given in the application settings page in Facebook. sessionKey is the second part of the access token. If the token is in this form, AAA|BBB|CCC, the BBB is the session key. sessionSecret is obtained using the old REST API with the method auth.promoteSession. To use it, it's needed to make a Http get to this url:

https://api.facebook.com/method/auth.promoteSession?access_token=yourAccessToken

Despite of the Facebook Chat documentation says that it's needed to use your application secret key, only when I used the key that returned that REST method I was able to make it works. To make that method works, you have to disable the Disable Deprecated Auth Methods option in the Advance tab in your application settings.

XMPP with Java Asmack library and X-FACEBOOK-PLATFORM

I tested this one and it worked for me .

First of all Edit your SASLXFacebookPlatformMechanism class . Copy and paste this code .

package com.facebook.android;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Map;

import org.apache.harmony.javax.security.auth.callback.CallbackHandler;
import org.apache.harmony.javax.security.sasl.Sasl;
import org.jivesoftware.smack.SASLAuthentication;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.sasl.SASLMechanism;
import org.jivesoftware.smack.util.Base64;

import android.util.Log;

public class SASLXFacebookPlatformMechanism extends SASLMechanism {

private static final String NAME = "X-FACEBOOK-PLATFORM";

private String apiKey = "";
private String accessToken = "";

/**
* Constructor.
*/
public SASLXFacebookPlatformMechanism(SASLAuthentication saslAuthentication) {
super(saslAuthentication);
}

@Override
protected void authenticate() throws IOException, XMPPException {
getSASLAuthentication().send(new AuthMechanism(NAME, ""));
}

@Override
public void authenticate(String apiKey, String host, String accessToken) throws IOException, XMPPException {
if (apiKey == null || accessToken == null) {
throw new IllegalArgumentException("Invalid parameters");
}

this.apiKey = apiKey;
this.accessToken = accessToken;
this.hostname = host;

String[] mechanisms = { "DIGEST-MD5" };
Map<String, String> props = new HashMap<String, String>();
this.sc = Sasl.createSaslClient(mechanisms, null, "xmpp", host, props, this);
authenticate();
}

@Override
public void authenticate(String username, String host, CallbackHandler cbh) throws IOException, XMPPException {
String[] mechanisms = { "DIGEST-MD5" };
Map<String, String> props = new HashMap<String, String>();
this.sc = Sasl.createSaslClient(mechanisms, null, "xmpp", host, props, cbh);
authenticate();
}

@Override
protected String getName() {
return NAME;
}

@Override
public void challengeReceived(String challenge) throws IOException {
byte[] response = null;

if (challenge != null) {
String decodedChallenge = new String(Base64.decode(challenge));
Map<String, String> parameters = getQueryMap(decodedChallenge);

String version = "1.0";
String nonce = parameters.get("nonce");
String method = parameters.get("method");

String composedResponse =
"method=" + URLEncoder.encode(method, "utf-8") +
"&nonce=" + URLEncoder.encode(nonce, "utf-8") +
"&access_token=" + URLEncoder.encode(accessToken, "utf-8") +
"&api_key=" + URLEncoder.encode(apiKey, "utf-8") +
"&call_id=0" +
"&v=" + URLEncoder.encode(version, "utf-8");
response = composedResponse.getBytes();
}

String authenticationText = "";

if (response != null) {
authenticationText = Base64.encodeBytes(response);
}

// Send the authentication to the server
getSASLAuthentication().send(new Response(authenticationText));
}

private Map<String, String> getQueryMap(String query) {
Map<String, String> map = new HashMap<String, String>();
String[] params = query.split("\\&");

for (String param : params) {
String[] fields = param.split("=", 2);
map.put(fields[0], (fields.length > 1 ? fields[1] : null));
}

return map;
}
}

Then use this method in your activity class from where you want to login to facebook.

private void testLogin(){
ConnectionConfiguration config = new ConnectionConfiguration("chat.facebook.com", 5222);
config.setSASLAuthenticationEnabled(true);
config.setSecurityMode(ConnectionConfiguration.SecurityMode.enabled);
xmpp = new XMPPConnection(config);
SASLAuthentication.registerSASLMechanism("X-FACEBOOK-PLATFORM",SASLXFacebookPlatformMechanism.class);
SASLAuthentication.supportSASLMechanism("X-FACEBOOK-PLATFORM", 0);
Log.i("XMPPClient",
"Access token to " + mFacebook.getAccessToken());
Log.i("XMPPClient",
"Access token to " + mFacebook.getAppId());
Log.i("XMPPClient",
"Access token to " + mFacebook.getAccessToken());
try {
xmpp.connect();
Log.i("XMPPClient",
"Connected to " + xmpp.getHost());

} catch (XMPPException e1) {
Log.i("XMPPClient",
"Unable to " + xmpp.getHost());

e1.printStackTrace();
}
try {
xmpp.login(PreferenceConnector.APP_ID, mFacebook.getAccessToken());

getRoster(xmpp);

} catch (XMPPException e) {
e.printStackTrace();
}
}

X-FACEBOOK-PLATFORM authentication with SMACK Java library using OAuth 2.0

There is a missing ampersand for the access_token parameter in the composedResponse string. Is this a typo?

Could you post the authenticationText you are sending?

How do I implement facebook chat using aSmack XMPP library in Android (2014)?

Authenticating using X-FACEBOOK-PLATFORM SASLAuthentication. Verified working on 14 Jan 2014 with Android 4.2.2.

The jabber ID is not username@chat.facebook.com. It is resolved to a numeric id that you can check against the roster.


ChatActivity.java

public void connectToFb() throws XMPPException {        
ConnectionConfiguration config = new ConnectionConfiguration("chat.facebook.com", 5222);
SASLAuthentication.registerSASLMechanism("X-FACEBOOK-PLATFORM",SASLXFacebookPlatformMechanism.class);
SASLAuthentication.supportSASLMechanism("X-FACEBOOK-PLATFORM", 0);
config.setSASLAuthenticationEnabled(true);
config.setSecurityMode(SecurityMode.required);
config.setSendPresence(false);
XMPPConnection xmpp = new XMPPConnection(config);
try {
xmpp.connect();
xmpp.login(Session.getActiveSession().getApplicationId(), Session.getActiveSession().getAccessToken(), "Application");

//send a chat message
ChatManager chatmanager = xmpp.getChatManager();
Chat newChat = chatmanager.createChat("<jabber-id-here>@chat.facebook.com", new MessageListener() {
@Override
public void processMessage(Chat chat, Message msg) {
Log.d("test", "message sent = "+ msg);
}
});
newChat.sendMessage("Cowdy!");

//get roster
Roster roster = xmpp.getRoster();
Collection<RosterEntry> entries = roster.getEntries();
System.out.println("Connected!");
System.out.println("\n\n" + entries.size() + " buddy(ies):");
for (RosterEntry entry : entries) {
Log.i("test", entry.getName());
Log.i("test", entry.getUser());
}
} catch (XMPPException e) {
xmpp.disconnect();
e.printStackTrace();
}
}

SASLXFacebookPlatformMechanism.java

public class SASLXFacebookPlatformMechanism extends SASLMechanism {

public static final String NAME = "X-FACEBOOK-PLATFORM";
private String apiKey = "";
private String accessToken = "";

/**
* Constructor.
*/
public SASLXFacebookPlatformMechanism(SASLAuthentication saslAuthentication) {
super(saslAuthentication);
}

@Override
protected void authenticate() throws IOException, XMPPException {
// Send the authentication to the server
getSASLAuthentication().send(new AuthMechanism(getName(), ""));
}

@Override
public void authenticate(String apiKey, String host, String accessToken) throws IOException, XMPPException {
this.apiKey = apiKey;
this.accessToken = accessToken;
this.hostname = host;

String[] mechanisms = { "DIGEST-MD5" };
Map<String, String> props = new HashMap<String, String>();
this.sc = Sasl.createSaslClient(mechanisms, null, "xmpp", host, props, this);
authenticate();
}

@Override
protected String getName() {
return NAME;
}

@Override
public void challengeReceived(String challenge) throws IOException {
byte[] response = null;

if (challenge != null) {
String decodedChallenge = new String(Base64.decode(challenge));
Map<String, String> parameters = getQueryMap(decodedChallenge);

String version = "1.0";
String nonce = parameters.get("nonce");
String method = parameters.get("method");

long callId = new GregorianCalendar().getTimeInMillis() / 1000L;

String composedResponse = "api_key=" + URLEncoder.encode(apiKey, "utf-8")
+ "&call_id=" + callId
+ "&method=" + URLEncoder.encode(method, "utf-8")
+ "&nonce=" + URLEncoder.encode(nonce, "utf-8")
+ "&access_token=" + URLEncoder.encode(accessToken, "utf-8")
+ "&v=" + URLEncoder.encode(version, "utf-8");

response = composedResponse.getBytes("utf-8");
}

String authenticationText = "";

if (response != null){
authenticationText = Base64.encodeBytes(response, Base64.DONT_BREAK_LINES);
}
// Send the authentication to the server
getSASLAuthentication().send(new Response(authenticationText));
}

private Map<String, String> getQueryMap(String query) {
Map<String, String> map = new HashMap<String, String>();
String[] params = query.split("\\&");

for (String param : params) {
String[] fields = param.split("=", 2);
map.put(fields[0], (fields.length > 1 ? fields[1] : null));
}
return map;
}
}

Authentication flow using SASL/Plain (when username and password are supplied)

Refer to Android Facebook chat example project

How to create XMPP chat client for facebook?

to get access token first you have to login

fb.authorize(FacebookActivity.this, new String[] {"xmpp_login"},Facebook.FORCE_DIALOG_AUTH, new DialogListner());

SASLXFacebookPlatformMecha class

import java.io.IOException;
import java.net.URLEncoder;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Map;

import org.apache.harmony.javax.security.auth.callback.CallbackHandler;
import org.apache.harmony.javax.security.sasl.Sasl;
import org.jivesoftware.smack.SASLAuthentication;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.sasl.SASLMechanism;
import org.jivesoftware.smack.util.Base64;

public class SASLXFacebookPlatformMecha extends SASLMechanism {

private static final String NAME = "X-FACEBOOK-PLATFORM";

private String apiKey = "";
private String access_token = "";

/**
* Constructor.
*/
public SASLXFacebookPlatformMecha(SASLAuthentication saslAuthentication) {
super(saslAuthentication);
}

@Override
protected void authenticate() throws IOException, XMPPException {

getSASLAuthentication().send(new AuthMechanism(NAME, ""));
}

@Override
public void authenticate(String apiKey, String host, String acces_token)
throws IOException, XMPPException {
if (apiKey == null || acces_token == null) {
throw new IllegalArgumentException("Invalid parameters");
}

this.access_token = acces_token;
this.apiKey = apiKey;
this.hostname = host;

String[] mechanisms = { NAME };
Map<String, String> props = new HashMap<String, String>();
this.sc = Sasl.createSaslClient(mechanisms, null, "xmpp", host, props,
this);
authenticate();
}

@Override
public void authenticate(String username, String host, CallbackHandler cbh)
throws IOException, XMPPException {
String[] mechanisms = { NAME };
Map<String, String> props = new HashMap<String, String>();
this.sc = Sasl.createSaslClient(mechanisms, null, "xmpp", host, props,
cbh);
authenticate();
}

@Override
protected String getName() {
return NAME;
}

@Override
public void challengeReceived(String challenge) throws IOException {
byte[] response = null;

if (challenge != null) {
String decodedChallenge = new String(Base64.decode(challenge));
Map<String, String> parameters = getQueryMap(decodedChallenge);

String version = "1.0";
String nonce = parameters.get("nonce");
String method = parameters.get("method");

long callId = new GregorianCalendar().getTimeInMillis();

String composedResponse = "api_key="
+ URLEncoder.encode(apiKey, "utf-8") + "&call_id=" + callId
+ "&method=" + URLEncoder.encode(method, "utf-8")
+ "&nonce=" + URLEncoder.encode(nonce, "utf-8")
+ "&access_token="
+ URLEncoder.encode(access_token, "utf-8") + "&v="
+ URLEncoder.encode(version, "utf-8");

response = composedResponse.getBytes("utf-8");
}

String authenticationText = "";

if (response != null) {
authenticationText = Base64.encodeBytes(response,
Base64.DONT_BREAK_LINES);
}

// Send the authentication to the server
getSASLAuthentication().send(new Response(authenticationText));
}

private Map<String, String> getQueryMap(String query) {
Map<String, String> map = new HashMap<String, String>();
String[] params = query.split("\\&");

for (String param : params) {
String[] fields = param.split("=", 2);
map.put(fields[0], (fields.length > 1 ? fields[1] : null));
}

return map;
}
}

I created ChatManager class

import org.jivesoftware.smack.Chat;
import org.jivesoftware.smack.ChatManagerListener;
import org.jivesoftware.smack.ConnectionConfiguration;
import org.jivesoftware.smack.MessageListener;
import org.jivesoftware.smack.Roster;
import org.jivesoftware.smack.RosterListener;
import org.jivesoftware.smack.SASLAuthentication;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.packet.Presence;
import org.jivesoftware.smack.packet.Presence.Type;
import org.jivesoftware.smackx.pubsub.PresenceState;

public class FacebookChatManager {

private static FacebookChatManager chatManager;
private XMPPConnection connection;
private final String SERVER = "chat.facebook.com";
private final int PORT = 5222;
private final String FACEBOOK_MECHANISM = "X-FACEBOOK-PLATFORM";
private RosterListener rosterListner;

private FacebookChatManager(RosterListener rosterListner)
{
this.rosterListner = rosterListner;
ConnectionConfiguration connFig = new ConnectionConfiguration(SERVER,
PORT);
connFig.setSASLAuthenticationEnabled(true);
connection = new XMPPConnection(connFig);
//setup facebook authentication mechanism
SASLAuthentication.registerSASLMechanism(FACEBOOK_MECHANISM,
SASLXFacebookPlatformMecha.class);
SASLAuthentication.supportSASLMechanism(FACEBOOK_MECHANISM, 0);
}

public static FacebookChatManager getInstance(RosterListener rosterListner)
{
if(chatManager == null)
{
chatManager = new FacebookChatManager(rosterListner);
}
return chatManager;
}

public boolean connect()
{
try {
connection.connect();
return true;
} catch (XMPPException e) {
e.printStackTrace();
connection.disconnect();
}
return false;
}

public void disConnect()
{
connection.disconnect();
}

public boolean logIn(String apiKey, String accessToken)
{
try {
connection.login(apiKey, accessToken);
setPresenceState(Presence.Type.available, "");
connection.getRoster().addRosterListener(rosterListner);
return true;
} catch (XMPPException e) {
connection.disconnect();
e.printStackTrace();
}
return false;
}

public Roster getRoster()
{
return connection.getRoster();
}

public Chat createNewChat(String user, MessageListener messageListner)
{
return connection.getChatManager().createChat(user, messageListner);
}

public void registerNewIncomingChatListner(ChatManagerListener chatManagerListner)
{
connection.getChatManager().addChatListener(chatManagerListner);
}

public void setPresenceState(Type precenseType, String status)
{
Presence presence = new Presence(precenseType);
presence.setStatus(status);
connection.sendPacket(presence);
}

public Presence getUserPresence(String userId)
{
return connection.getRoster().getPresence(userId);
}
}

at the end to use that FacebookChatManager class note that rosterListnr is used to get info about your friends state change implement one as you want

FacebookChatManager facebookChatManager = FacebookChatManager.getInstance(rosterListner);

if (facebookChatManager.connect()) {
if (facebookChatManager.logIn(FacebookActivity.APP_ID,
access_token)) {
return facebookChatManager.getRoster();
}
}

Android Chat App with XMPP

put asmack library in libs folder.

Android Facebook chat example project

There is one Android Open Source Project available : Beem Project and For connecting Facebook chat you can used following guidelines.

Steps for implementing Facebook chat API in Android:

  1. First we have to implement MemorizingTrustManager Library project in existing project.

    => For that you have to copy following three files in existing project

    • MemorizingTrustManager/src/de/duenndns/ssl/MTMDecision.java
    • MemorizingTrustManager/src/de/duenndns/ssl/MemorizingActivity.java
    • MemorizingTrustManager/src/de/duenndns/ssl/MemorizingTrustManager.java

    => And add following values in values/string.xml

    <resources>
    <string name="mtm_accept_cert">Accept Unknown Certificate?</string>
    <string name="mtm_decision_always">Always</string>
    <string name="mtm_decision_once">Once</string>
    <string name="mtm_decision_abort">Abort</string>
    <string name="mtm_notification">Certificate Verification</string>
    </resources>
  2. Second step, Instead of using SASLAuthentication such as X-FACEBOOK-PLATFORM, You can used following code to connect with Facebook and login using your Facebook Jabber ID (username@chat.facebook.com)

    public void connectToFb() throws XMPPException {

    ConnectionConfiguration config = new ConnectionConfiguration("chat.facebook.com", 5222);
    config.setSASLAuthenticationEnabled(true);
    config.setSecurityMode(SecurityMode.required);
    config.setRosterLoadedAtLogin(true);
    config.setTruststorePath("/system/etc/security/cacerts.bks");
    config.setTruststorePassword("changeit");
    config.setTruststoreType("bks");
    config.setSendPresence(false);
    try {
    SSLContext sc = SSLContext.getInstance("TLS");
    sc.init(null, MemorizingTrustManager.getInstanceList(this), new java.security.SecureRandom());
    config.setCustomSSLContext(sc);
    } catch (GeneralSecurityException e) {
    Log.w("TAG", "Unable to use MemorizingTrustManager", e);
    }
    XMPPConnection xmpp = new XMPPConnection(config);
    try {
    xmpp.connect();
    xmpp.login("facebookusername", "****"); // Here you have to used only facebookusername from facebookusername@chat.facebook.com
    Roster roster = xmpp.getRoster();
    Collection<RosterEntry> entries = roster.getEntries();
    System.out.println("Connected!");
    System.out.println("\n\n" + entries.size() + " buddy(ies):");
    // shows first time onliners---->
    String temp[] = new String[50];
    int i = 0;
    for (RosterEntry entry : entries) {
    String user = entry.getUser();
    Log.i("TAG", user);
    }
    } catch (XMPPException e) {
    xmpp.disconnect();
    e.printStackTrace();
    }
    }

At last, If you get all the Buddy list of your Facebook account in LogCat View, than you can implement simple Facebook chat using this tutorial.



Related Topics



Leave a reply



Submit