2015-03-01

Facebook Integration


I've spent the past few weeks adding Facebook integration to Zoing. This took much longer than I expected, but it is working now and I only need to finish testing and release. I didn't carefully document why this took longer than expected, but I still want to write a little about my experience in case it might help others.

You can see what this looks like (for iOS) in the picture to the right. Below that is how it ends up in your Facebook timeline.

First, as I've written before, I'm using Xamarin to develop on both iOS and Android in C#. This allows me to work in a language I'm proficient in, and reuse a lot of the code. This works really well for areas of the code that are platform agnostic, such as the core logic for my game. It also works quite well for areas that have a good common library, such as using OpenTK, which is a C# wrapper for OpenGL. But, sharing code becomes less useful when a library is not consistent across platforms, which is the case for Facebook's SDK for iOS and Android.

I can't think of any good reason that Facebook's SDK is so different for these two platforms. Sure there are differences in the way that most Apple libraries work and Android libraries work. Some of that is due to best practices for Objective C versus those for Java. But, I don't see the differences in Facebook's library is being beneficial in any way. It means that anyone who wants to integrate Facebook into their apps essentially needs to learn how to use the library twice.

Making this even worse is that the documentation from Facebook is not very good. It is quite extensive, but just not good. There are very few examples, and they are hard to find. The one I found that ended up helping me the most was the sample game they have, FriendSmash. They have a complete sample project for both iOS and Android.

I should mention that this problem is made even more complex for me because I'm also using Xamarin's Facebook SDK wrapper, which is available in separate versions for iOS and Android. From my perspective Xamarin would ideally provide a single library for Facebook integration that has a consistent API, and that there are platform specific layers to have that library communicate with Facebook's iOS and Android libraries.

One Library to Rule Them All

To make my life easier, this is exactly what I set out to create. I'm currently only using a small subset of all Facebook functionality. Basically I only need to: login and logout, retrieve the user's profile image, and post a custom story. There were two key reasons I wanted this single cross-platform library. One was because I also plan to integrate other social networks, at least Twitter and maybe others. Ideally I can interface with these via a consistent API. The second was because most of my code is platform agnostic and can't call any platform specific functions. Creating this library allows me to perform these Facebook actions from within my code.

To support this I created the following abstract class:
using System;

namespace Qythyx.Infrastructure.SocialNetwork
{
    /// <summary>Provides functionality related to a social network platform.</summary>
    public abstract class SocialNetworkConnector
    {
        private bool _isLoggedIn;

        /// <summary>Initializes a new instance of <see cref="SocialNetworkConnector"/>.</summary>
        protected SocialNetworkConnector()
        {
            Login(false, _ => {});
        }

        /// <summary>Gets a value indicating whether this <see cref="SocialNetworkConnector"/> is displaying dialog.</summary>
        /// <value><c>true</c> if displaying a dialog; otherwise, <c>false</c>.</value>
        public bool DisplayingDialog { get; private set; }

        /// <summary>Logs in to the social network and then calls the specified callback.</summary>
        /// <param name="showUI">Determines if the login UI should be shown or not.</param>
        /// <param name="callback">An action that is called after the login completes
        /// with either <c>true</c> if the login was successful; otherwise <c>false</c>.</param>
        public void Login(bool showUI, Action<bool> callback)
        {
            DisplayingDialog = showUI ? true : false;
            PerformLogin(
                showUI,
                success =>
                {
                    if(!success)
                    {
                        Logout();
                    }
                    IsLoggedIn = success;
                    callback(success);
                    DisplayingDialog = false;
                }
            );
        }

        /// <summary>Logs in to the social network and then calls the specified callback.</summary>
        /// <param name="showUI">Determines if the login UI should be shown or not.</param>
        /// <param name="callback">An action that is called after the login completes
        /// with either <c>true</c> if the login was successful; otherwise <c>false</c>.</param>
        protected abstract void PerformLogin(bool showUI, Action<bool> callback);

        /// <summary>Logs out of the social network.</summary>
        public void Logout()
        {
            PerformLogout();
            IsLoggedIn = false;
        }

        /// <summary>Logs out of the social network.</summary>
        protected abstract void PerformLogout();

        /// <summary>Gets or sets a value indicating whether the user is logged in to the social network.</summary>
        /// <value><c>true</c> if logged in; otherwise, <c>false</c>.</value>
        public bool IsLoggedIn
        { 
            get
            {
                return _isLoggedIn;
            }
            set
            {
                _isLoggedIn = value;
                if(value)
                {
                    GetUserImage(
                        (success, userImage) =>
                        {
                            UserImage = userImage;
                            if(LoggedInStateChangeEventHandler != null)
                            {
                                LoggedInStateChangeEventHandler(true, UserImage);
                            }
                        }
                    );
                }
                else
                {
                    if(LoggedInStateChangeEventHandler != null)
                    {
                        LoggedInStateChangeEventHandler(false, null);
                    }
                }
            }
        }

        /// <summary>The user's image.</summary>
        public ShareDetails UserImage { get; private set; }

        /// <summary>Occurs when logged in state changes.
        /// The <c>bool</c> determines if the user is logged in.
        /// In general if the user is logged in then the <see cref="ShareDetails"/> should hold the user's image,
        /// but if there was a problem retrieving it then this will be <c>null</c>.
        /// </summary>
        public event Action<bool, ShareDetails> LoggedInStateChangeEventHandler;

        /// <summary>Attempts to login if necessary and, if logged in, performs the given action.</summary>
        /// <param name="action">The action to perform if login is successful.</param>
        public void ConfirmLoginAndDoAction(Action action)
        {
            if(!IsLoggedIn)
            {
                Login(true, success => { if(success) action(); });
            }
            else
            {
                action();
            }
        }

        /// <summary>Retrieves the user's profile image from the social network and, once it is retrieved, passes it to the callback.
        /// The callback is passed a boolean denoting if retrieval was successful and the image information. The image format is RGBA.
        /// If retrieval fails the image data will be <c>null</c>.</summary>
        /// <param name="callback">A callback that is called after retrieval completes.</param>
        protected abstract void GetUserImage(Action<bool, ShareDetails> callback);

        /// <summary>Shares the details of completing a level to the social network.</summary>
        /// <param name="details">The <see cref="ShareDetails"/> describing the accomplishment.</param>
        public abstract void ShareLevelCompletion(ShareDetails details);
    }
}
This defines all of the functionality I need for Facebook. Unfortunately, to implement this for iOS and Android takes quite a bit of code, and as I said above, it's quite different for each platform. Also, unfortunately, to hook in the functionality for each platform requires bits and pieces of code or configuration in multiple files, which is kind of a pain. I'll try to explain what I did to get this working on both platforms.

iOS

AppDelegate.cs

You need to initialize the Facebook library during you app startup and the recommended place to do that is in the AppDelegate.FinishedLaunching method by adding the following. You'll also need to add using MonoTouch.FacebookConnect; to this file.
FBSettings.DefaultAppID = "your Facebook app id";
FBSettings.DefaultDisplayName = "your Facebook app display name";
So, where does that app id come from? Well, that's another important piece of setting up Facebook integration. This is explained in Facebook's "getting started" documentation for iOS and Android, although reading through the instructions left me with a lot of questions that took a while to finally resolve.

Info.plist

Next you need to add your app ID to your Info.plist file. This is also explained in Facebook's documentation, although it also could be clearer.

FacebookConnector.cs

After this I needed to implement the iOS version of my SocialNetworkConnector:
using CoreGraphics;
using Foundation;
using MonoTouch.FacebookConnect;
using System;
using System.Collections.Generic;
using System.Globalization;
using UIKit;
using Qythyx.Infrastructure.Extensions;
using Qythyx.Infrastructure.SocialNetwork;

namespace Qythyx.ZoingiOS
{
    /// <summary>Provides functionality related to a Facebook.</summary>
    public sealed class FacebookConnector : SocialNetworkConnector
    {
        private static readonly string[] _ExtendedPermissions = new [] { "user_about_me", "publish_actions" };

        /// <summary>Initializes a new instance of <see cref="FacebookConnector"/>.</summary>
        public FacebookConnector()
            : base()
        {
        }

        /// <summary>Logs in to the social network and then calls the specified callback.</summary>
        /// <param name="showUI">Determines if the login UI should be shown or not.</param>
        /// <param name="callback">An action that is called after the login completes
        /// with either <c>true</c> if the login was successful; otherwise <c>false</c>.</param>
        protected override void PerformLogin(bool showUI, Action<bool> callback)
        {
            FBSession.OpenActiveSession(
                _ExtendedPermissions,
                showUI,
                new SessionStateHandler(callback).FBSessionStateHandler
            );
        }

        /// <summary>Logs out of the social network.</summary>
        protected override void PerformLogout()
        {
            FBSession.ActiveSession.CloseAndClearTokenInformation();
        }

        /// <summary>Retrieves the user's profile image from the social network and, once it is retrieved, passes it to the callback.
        /// The callback is passed a boolean denoting if retrieval was successful and the image information. The image format is RGBA.
        /// If retrieval fails the image data will be <c>null</c>.</summary>
        /// <param name="callback">A callback that is called after retrieval completes.</param>
        protected override void GetUserImage(Action<bool, ShareDetails> callback)
        {
            FBRequest.ForMe.Start(
                new RequestHandler(
                    (success, result) =>
                    {
                        ShareDetails details = null;
                        if(success)
                        {
                            FBGraphObject userInfo = (FBGraphObject)result;
                            string imageUrl = String.Format(
                                CultureInfo.InvariantCulture,
                                "https://graph.facebook.com/{0}/picture?width={1}&height={1}", 
                                userInfo["id"].ToString(),
                                Zoing.Constants.SocialUserImageSize
                            );
                            details = GetFacebookUserImage(imageUrl);
                        }
                        callback(success, details);
                    }
                ).FBRequestHandler
            );
        }

        private static ShareDetails GetFacebookUserImage(string imageUrl)
        {
            try
            {
                NSData data = NSData.FromUrl(new NSUrl(imageUrl));

                using(CGImage image = UIImage.LoadFromData(data).CGImage)
                {
                    int scaledWidth = ((Int32)image.Width).NearestSmallerPowerOf2();
                    int scaledHeight = ((Int32)image.Height).NearestSmallerPowerOf2();

                    byte[] pixels = new byte[scaledWidth * scaledHeight * 4];
                    using(CGBitmapContext context = new CGBitmapContext(pixels, scaledWidth, scaledHeight, 8, 4 * scaledWidth, CGColorSpace.CreateDeviceRGB(), CGBitmapFlags.NoneSkipLast))
                    {
                        context.DrawImage(new CGRect(0, 0, scaledWidth, scaledHeight), image);
                        return new ShareDetails(String.Empty, pixels, scaledWidth, scaledHeight);
                    }
                }
            }
            catch(Exception)
            {
                return null;
            }
        }


        private static void StageImage(UIImage image, Action<bool, string> completionAction)
        {
            // Using FBRequestConnection.UploadStagingResource fails with a cast exception, but the following works.
            FBRequest request = FBRequest.ForUploadStagingResource(image);
            request.Start(
                new RequestHandler(
                    (success, result) => completionAction(success, success ? ((FBGraphObject)result)["uri"].ToString() : null)
                ).FBRequestHandler
            );
        }

        /// <summary>Shares the details of completing a level to the social network.</summary>
        /// <param name="details">The <see cref="ShareDetails"/> describing the accomplishment.</param>
        public override void ShareLevelCompletion(ShareDetails details)
        {
            ObjCRuntime.Class.ThrowOnInitFailure = false;
            UIImage image = CreateUIImage(details);
            StageImage(
                image,
                (success, url) =>
                {
                    if(success)
                    {
                        ShareLevelCompletion(details.Message, image, url);
                    }
                }
            );
        }

        private static UIImage CreateUIImage(ShareDetails details)
        {
            const int facebookSize = Qythyx.Zoing.Constants.FacebookImageSize;
            CGBitmapContext context = new CGBitmapContext(null, facebookSize, facebookSize, 8, 0, CGColorSpace.CreateDeviceRGB(), CGBitmapFlags.NoneSkipLast);

            using(
                CGImage image = new CGImage(
                    details.Width,
                    details.Height,
                    8,
                    32,
                    details.Width * 4,
                    CGColorSpace.CreateDeviceRGB(),
                    CGBitmapFlags.None,
                    new CGDataProvider(details.Image, 0, details.Image.Length),
                    null,
                    false,
                    CGColorRenderingIntent.Default
                )
            )
            {
                double scale = Math.Max((double)details.Width / facebookSize, (double)details.Height / facebookSize);
                int scaledWidth = (int)(details.Width / scale);
                int scaledHeight = (int)(details.Height / scale);
                int left = (facebookSize - scaledWidth) / 2;
                int top = (facebookSize - scaledHeight) / 2;
                context.InterpolationQuality = CGInterpolationQuality.High;
                context.DrawImage(new CGRect(left, top, scaledWidth, scaledHeight), image);
            }

            return new UIImage(context.ToImage());
        }
        
        private static void ShareLevelCompletion(string title, UIImage image, string imageUrl)
        {
            // From https://developers.facebook.com/docs/applinks/hosting-api
            const string url = "https://fb.me/_____your app's URL, see above link____";

            var fbOpenGraphObject = FBGraphObject.OpenGraphObjectForPost(
                "zoing-game:game_level",
                title,
                new NSString(imageUrl),
                new NSString(url),
                Qythyx.Zoing.Constants.ZoingDescription
            );
            
            FBGraphObject graphObject = (FBGraphObject)ObjCRuntime.Runtime.GetNSObject(fbOpenGraphObject.Handle);

            var graphAction = FBGraphObject.OpenGraphAction;
            graphAction.SetObject(new NSString("game_level"), "preview property");
            graphAction.SetObject(graphObject, "game_level");
            graphAction.SetObject(new NSString("true"), "fb:explicitly_shared");
            graphAction.SetObject(image, "image");

            FBOpenGraphActionParams fbParams = new FBOpenGraphActionParams(graphAction, "me/zoing-game:complete", "game_level");

            if(FBDialogs.CanPresentShareDialog(fbParams))
            {
                FBDialogs.PresentShareDialog(
                    graphAction, 
                    "zoing-game:complete",
                    "game_level",
                    (call, results, error) =>
                    {
                        // do nothing, either the share was successful or not, nothing to do about it
                    }
                );
            }
            else
            {
                Dictionary<string, object> webParameters = new Dictionary<string, object>() {
                    { "name", title },
                    { "caption", "Zoing" },
                    { "description", Qythyx.Zoing.Constants.ZoingDescription },
                    { "link", url },
                    { "picture", "https://sites.google.com/a/qythyx.com/www/Zoing%20Icon%20180x180.png" },
                };

                var s = GetNSDictionary(webParameters)["picture"];

                // FBWebDialogs.PresentDialogModally failed with an exception, so using this instead.
                FBWebDialogs.PresentFeedDialogModally(
                    FBSession.ActiveSession,
                    GetNSDictionary(webParameters),
                    (result, resultsUrl, error) => { /* do nothing */ }
                );
            }
        }

        private static NSDictionary GetNSDictionary(Dictionary<string,object> dictionary)
        {
            NSMutableDictionary nsd = new NSMutableDictionary();
            foreach(var entry in dictionary)
            {
                nsd.Add(new NSString(entry.Key), NSObject.FromObject(entry.Value));
            }
            return nsd;
        }

        private class RequestHandler
        {
            private readonly Action<bool, NSObject> _completionHandler;

            public RequestHandler(Action<bool, NSObject> completionHandler)
            {
                _completionHandler = completionHandler;
            }

            public void FBRequestHandler(FBRequestConnection connection, NSObject result, NSError error)
            {
#if DEBUG
                if(error != null)
                {
                    Console.WriteLine("Facebook Request Error: " + error.ToString());
                    if(connection.UrlResponse != null)
                    {
                        Console.WriteLine("Facebook URLResponse: " + connection.UrlResponse.ToString());
                    }
                }
#endif
                _completionHandler(error == null, result);
            }
        }

        private sealed class SessionStateHandler
        {
            private readonly Action<bool> _action;

            public SessionStateHandler(Action<bool> action)
            {
                _action = action;
            }

            public void FBSessionStateHandler(FBSession session, FBSessionState state, NSError error)
            {
#if DEBUG
                if(error != null)
                {
                    Console.WriteLine("Facebook Request Error: " + error.ToString());
                }
#endif
                _action(SessionStateIsLoggedIn(state));
            }
        }

        private static bool SessionStateIsLoggedIn(FBSessionState state) 
        {
            return state != FBSessionState.ClosedLoginFailed && state != FBSessionState.Closed && state != FBSessionState.CreatedOpening;
        }
    }
}

Hooking It All Together

Obviously there's a bit more to it than the above. For example, attaching something to the base class's Info.plistLoggedInStateChangeEventHandler to actually use the user's image that is returned. All of that stuff will be different and specific to each app and I can't document exactly how everyone should use this.

Anyway, I hope this helps someone else. I'll try to document the Android version soon. It's a bit more complex, but does implement my consistent abstract class, and after hooking everything up correctly, works pretty well.

No comments:

Post a Comment