Click here to Skip to main content
Click here to Skip to main content

Friends Radar - A geo-location based friend discovery app for Windows 8

, 25 Nov 2012
Rate this:
Please Sign up or sign in to vote.
This article describes an app that finds friends nearby. It's an App Innovation Contest entry.

Introduction

As the title has suggested, Friends Radar is a geo-location based social app. Basically, it lets you pick some friends from your Microsoft Account (or Facebook, Foursquare for that matter), and get notifications when they approach. Additionally, user can also search for friends within a certain distance, say 100m. Of course, friends management, app settings, and sync between devices, etc needs to be there. Also it will have the ability to integrate with the Search charm and lock screen. But bear in mind, I just got started on implementing it, and it won't cure cancer just yet.

Background

You need to have basic knowledge of C#, WinRT app development or other XAML technology to understand the code snippets presented in this article.

Why WinRT?

Going WinRT basically means that I must follow certain UI style guidelines, live in a sandbox while coding (I'm sure there are ways to reach out of the box, but for most of the time, the sandboxing is to be expected), and wash my hands of app hosting/installing/updating for the most part. A lot of freedom is taken away from me as a coder who likes control. But also it also means that I can focus more energy on the core features instead of worrying the setup technology, etc. Also WinRT has built-in contact management which simplify a lot of things in some scenarios. And Windows Notification Service is a godsend if you need push notifications in your app. Plus I do love the Metro style, it's quite refreshing, artistic and a long swing away from traditional thinking in terms of the UI.

Location, Location, Location!!!

Back in college, I majored in GIS. Most of the subjects are boring to me. And I convinced myself that every time I attend a charting class, a kitty died. So I escaped almost every single one of them. But there is one subject that did interest me to the core - GPS. I got amazed by it, and had imagined a lot of applications for it. One of them was finding whereabouts of my friends, hence Friends Radar.

Retrieving the Location Info?

For Friends Radar to work, we need to retrieve near real-time location info of the friends of a certain user, there are two ways. If a friend also have Friends Radar installed, we can have their location broadcasted to his friends, which includes our user. Of course, the user has to be aware of that their location info is published to their friends. The other way involves more work. Facebook has location api, so does Foursquare. We can leverage these two platforms. But trouble is that the location info of the two can hardly be regard as real-time or near real-time. Also Foursquare does not have a WinRT SDK yet, not even one from third-party. So it leaves us only Facebook.

Given that, at least for now, we'll use only the former approach - retrieve location info and broadcast to selected friends.

Retrieving location in WinRT is trivial, like below

var locator = new Geolocator
{
    DesiredAccuracy = PositionAccuracy.Default;
};
Geoposition position = await loc.GetGeopositionAsync();
App.LastKnownLocation = position;    //Cache the position to be used by manual friends search

//or alternatively
locator.PositionChanged += (sender, e) =>
{
    Geoposition position = e.Position;
    App.LastKnownLocation = position;    //Cache the position to be used by manual friends search
};

Although this is not particularly difficult, but there is one issue that needs to be considered. The retrieval of location could take up quite sometime, and we need to access location info quite often. So we need to cache the "Last Know Position", and when user search for friends within certain range manually, we kick off the search using the cached "Last Know Position", and as soon as the actual location fix is received, we compare it with "Last Known Position". And if the "Last Known Position" falls in a permissible range with the actual fix, we use the result as final, otherwise, we restart the search.

But for location broadcasting, we don't need to use the cached location, but the location needs to be cached anyway since it can be used for manual friends search.

Another possible concern is location accuracy, we may need to take different approach for lower accuracy mode such as WiFi triangulation (100-350m), IP Resolution (>=25, 000m). We discard this concern for the sake of simplicity and will revisit it in a later time.

Pick on Your Friends

Given that Windows 8 has "People" hub, we could use Windows built-in Contact api to let user pick friends that will gets looped in the Friends Radar. But it has limited set of features and that's why I choose Live SDK instead. Of course if we're to go with Facebook or Foursquare, we need to be able to pick friends from those services. But for now, we use Live SDK. Given that Facebook can be connected to Microsoft Account, Facebook's contact system is supported, partially.

The UI for friends picking is inspired by the "People" hub in Windows 8. But since retrieving account picture could take quite some time, only text info is shown, like below:

Contact Picker Mockup

To achieve this kind of grouped UI is not easy with WinRT XAML. No built-in control that does this. But lucky for me, I found out an blog post discuessing about how to implement a People hub like list. The gist of
it is to use DataTemplateSelector to display groups differently from items. By using DataTemplateSelector (or ItemTemplateSelector), one can dynamically control certain aspect of the items on a per-items
basis, like below:

class ItemOrHeaderSelector: DataTemplateSelector
{
    public DataTemplate Group { get; set; }
    public DataTemplate Item { get; set; }
    
    protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
    {
        var itemType = item.GetType();
        // Use generic type name to determine whether an item is a group
        var isGroup = itemType.Name == "Group`1" && 
             itemType.Namespace == "NogginBox.WinRT.Extra.Collections";

        // Disable headers so they can't be selected
        var selectorItem = container as SelectorItem;
        if(selector != null)
        {
            selectorItem.IsEnabled = !isGroup;
        }
        
        return isGroup? Group : Item;
    }
}

The XAML looks something like this

<selectors:ItemOrHeaderSelector
    x:Key="FilmItemOrHeaderSelector"
    Item="{StaticResource FilmItem}"
    Group="{StaticResource FilmHeader}" />
...
<GridView ItemsSource="{Binding ItemsWithHeaders}" 
  ItemTemplateSelector="{StaticResource FilmItemOrHeaderSelector}" SelectionMode="None" />

I managed to mimic the UI for the most part other than a few minor differences. It's not easy to fix those inconsistence and make them almost identical as that I'm no designer. The code for it is somehow hacky, I'll post the code after polishing a bit.

Upon the completion of picking friends from a user's Microsoft Account, an email will be sent to those friends on behalf of the user for them to download and install Friends Radar onto their machine if they don't have it already. This is done through Windows Azure Mobile Services' latest addition - sending emails using SendGrid in server script

var sendgrid = new SendGrid('<< account name >>', '<< password >>');
sendgrid.send({
    to: '<< email of a friend >>',
    from: 'notifications@friends-radar.azure-mobile.net',    
    subject: 'XXX invites you to join Friends Radar',
    text: 'XXX has looped you into his/her friends radar, confirm this request by installing Friends Radar if this is your Microsoft Account email.' + '\r\Otherwise, you can use this invitation code - xxxxxx to accept this request after installing Friends Radar'
     }, function (success, message) {
    if(!success) {
        console.error(message);
    }
});    

This seems extremely trivial to me. Cloud is simply amazing!

After a friend installed Friends Radar, it will be looped into the user's radar screen and show up in a friend search.

Grouping

User can and should be able to group their friends into different group so they can selectively only listen to certain friends' location updates - which is called pulses in Friends Radar.  

Contextual Friends Interactions 

After a friend is within a given distance/range (aka found on the radar screen),  user can interact with them by sending toast notifications, or email, or initiate a IM conversation, a Video Call, provided that Skype opens up its API, or even a tweet. The available interactions should be contextual, let's say it's 12:00 o'clock, hey, it's lunch time, then you can send a dinner invitation to your friends. Or there is a Starbucks right around a corner, you can send a coffee drinking initiative. Or user can just send a simple message to friends through push notifications.  Anyway,  lots of possibilities. 

The Backend - Windows Azure Mobile Services 

Despite its hideous name, Windows Azure Mobile Services is quite and God-send for writing cloud-connected app. Within minutes, you can have the app exchange data with Windows Azure back and forth. It really nailed it. So for backend, Windows Azure Mobile Services is chosen for its simplicity, reliability and speed of development.

I specifically like the dynamic scheme feature of it. It feels like magic. Let me explain it with code screenshots and code.

When creating a new Windows Azure Mobile Services table, there is only one "id" column, like below

New Table

But with dynamic schema enabled, you can change the table's schema upon new data's first insertion. You can eanble dynamic schema under "Configure" tab like below:

Enable Dynamic Schema

By default, it's on.

Then in your code, just add some more member to the model like this:

public class User
{
	public int Id { get; set; }

	[DataMember(Name = "firstname")]
	public string FirstName { get; set; }

	[DataMember(Name = "lastname")]
	public string LastName { get; set; }

	[DataMember(Name = "email")]
	public string Email { get; set; }
}

Then magically, when you insert some data into the table by using IMobileServiceTable<User>.InsertAsynce, Windows Azure Mobile Services picks up the change and update schema accordingly. Just
amazing!

For the second edition of this article, I've been mostly focused on the backend as I found that the UI part for the app is particularly hard for me after trying to mimic the built-in Contact Picker. After all, I'm no designer. But even for the super simplicity of Windows Azure Mobile Service, I hit a few roadblocks. Luckily, all of those are removed.

Table Schema 

The schema of friends-radar has 11 tables, as shown below:

Below is the column model of each table, depicted as C# code

 
    /// <summary>
    /// Used to record user activities
    /// </summary>
    [DataTable(Name = "activities")]
    public class Activity
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "deviceId")]
        public int DeviceId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }

        /// <summary>
        /// The type of the activity, could be friend invitations, 
        /// interactions (a dinner/drink invitation, etc.), or 
        /// a friend shows up on the radar
        /// </summary>
        [DataMember(Name = "type")]
        public string Type { get; set; }

        [DataMember(Name = "description")]
        public string Description { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        [DataMember(Name = "latitude")]
        public double Latitude { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        /// <summary>
        /// The human-readable address corresponding to the location 
        /// specified by Latitude and Longitude
        /// </summary>
        [DataMember(Name = "address")]
        public string Address { get; set; }
    }
    
    /// <summary>
    /// Used to store the channel url for each user and installation in Mobile Services
    /// </summary>
    [DataTable(Name = "devices")]
    public class Device
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "installationId")]
        public string InstallationId { get; set; }

        [DataMember(Name = "channelUri")]
        public string ChannelUri { get; set; }
    }
    
    /// <summary>
    /// Used to store info of friend group
    /// </summary>
    [DataTable(Name = "groups")]
    public class Group
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "name")]
        public string Name { get; set; }

        [DataMember(Name = "expirationDate")]
        public DateTime ExpirationDate { get; set; }

        [DataMember(Name = "created")]
        public DateTime Created { get; set; }

        [DataMember(Name = "userId")]
        public string UserId;
    }
    
    /// <summary>
    /// Used to store info of members of a friend group
    /// </summary>
    [DataTable(Name = "groupMembers")]
    public class GroupMember
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "groupId")]
        public int GroupId { get; set; }

        [DataMember(Name = "memberUserId")]
        public string MemberUserId { get; set; }

        [DataMember(Name = "groupOwnerUserId")]
        public string GroupOwnerUserId { get; set; }
    }
    
    /// <summary>
    /// Represents an invite requesting another user to join Friends Radar
    /// </summary>
    [DataTable(Name = "invites")]
    public class Invite
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "fromUserId")]
        public string FromUserId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }

        [DataMember(Name = "fromUserName")]
        public string FromUserName { get; set; }

        [DataMember(Name = "fromImageUrl")]
        public string FromImageUrl { get; set; }

        [DataMember(Name = "toUserName")]
        public string ToUserName { get; set; }

        [DataMember(Name = "toUserEmail")]
        public string ToUserEmail { get; set; }

        /// <summary>
        /// If a friend is not using Microsoft Account, they can 
        /// use invite code to join your friends radar
        /// </summary>
        [DataMember(Name = "inviteCode")]
        public string InviteCode { get; set; }

        [DataMember(Name = "approved")]
        public bool Approved { get; set; }
    }
    
    /// <summary>
    /// Used to store info of friends interactions
    /// </summary>
    [DataTable(Name = "interactions")]
    public class Interaction
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "fromUserId")]
        public string FromUserId { get; set; }

        [DataMember(Name = "fromDeviceId")]
        public int FromDeviceId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }

        /// <summary>
        /// The type of the interaction, like a dinner invitation, 
        /// a drink invitation, a get-together call, etc. 
        /// </summary>
        [DataMember(Name = "type")]
        public string Type { get; set; }

        [DataMember(Name = "message")]
        public string Message { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        [DataMember(Name = "latitude")]
        public double Latitide { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        /// <summary>
        /// The human-readable address corresponding to the location 
        /// specified by Latitude and Longitude
        /// </summary>
        [DataMember(Name = "address")]
        public string Address { get; set; }
    }
    
    [DataTable(Name = "operations")]
    public class Operation
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        /// <summary>
        /// The operation id. Right now, there is only two
        /// 1. for updateLocation - ask all of the friends to update their location
        /// 2. for search - ask all of the friends to report whether they meat the 
        ///    certain search criterias
        /// </summary>
        [DataMember(Name = "operationId")]
        public int OperationId { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        #region Search criterias
        [DataMember(Name = "latitude")]
        public double Latitude { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        [DataMember(Name = "searchDistance")]
        public double SearchDistance { get; set; }

        [DataMember(Name = "friendName")]
        public string FriendName { get; set; } 
        #endregion
    }
    
    /// <summary>
    /// A class used to store information about users in Mobile Services
    /// </summary>
    [DataTable(Name = "profiles")]
    public class Profile
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        private string _name;
        [DataMember(Name = "name")]
        public string Name { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }

        [DataMember(Name = "city")]
        public string City { get; set; }
        
        [DataMember(Name = "state")]
        public string State { get; set; }

        [DataMember(Name = "created")]
        public string Created { get; set; }

        [DataMember(Name = "imageUrl")]
        public string ImageUrl { get; set; }

        [DataMember(Name = "accountEmail")]
        public string AccountEmail { get; set; }
    }
    
    /// <summary>
    /// Represents a update of user's location info
    /// </summary>
    [DataTable(Name = "pulses")]
    public class Pulse
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public string UserId { get; set; }
       [DataMember(Name = "userName")]
       public string UserName { get; set; }  
        [DataMember(Name = "deviceId")]
        public int DeviceId { get; set; }

        [DataMember(Name = "latitude")]
        public double Latitude { get; set; }

        [DataMember(Name = "longitude")]
        public double Longitude { get; set; }

        /// <summary>
        /// The human-readable address corresponding to the location 
        /// specified by Latitude and Longitude
        /// </summary>
        [DataMember(Name = "address")]
        public string Address { get; set; }

        [DataMember(Name = "when")]
        public DateTime When { get; set; }

        #region Search criteria related properties
        [DataMember(Name = "searchDistance")]
        public int SearchDistance { get; set; }

        /// <summary>
        /// How much time difference we can tolerate when do a search
        /// </summary>
        [DataMember(Name = "searchTimeTolerance")]
        public int SearchTimeTolerance { get; set; }

        [DataMember(Name = "friendName")]
        public string FriendName { get; set; } 
        #endregion
    }
    
    /// <summary>
    /// Store friend relationships data
    /// One thing to remember is that when user A and user B are friends,
    /// then the record of (fromUserId = <user A id>, toUserId = <user B id>)
    /// and (fromUserId = <user B id>, toUserId = <user A id>) should both 
    /// exists. This simplify things a bit when determining the relationship 
    /// of two users
    /// </summary>
    [DataTable(Name = "relationships")]
    public class Relationship
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "fromUserId")]
        public string FromUserId { get; set; }

        [DataMember(Name = "toUserId")]
        public string ToUserId { get; set; }
    }
    
    /// <summary>
    /// Store user settings
    /// </summary>
    [DataTable(Name = "settings")]
    public class Setting
    {
        [DataMember(Name = "id")]
        public int Id { get; set; }

        [DataMember(Name = "userId")]
        public int UserId { get; set; }

        [DataMember(Name = "key", IsRequired = true)]
        public string Key { get; set; }

        [DataMember(Name = "value", IsRequired = true)]
        public string Value { get; set; }
    }
    

Server Scripts 

Windows Azure Mobile Service Server Script (quite a mouthfull I know) is based on Node.js. I heard of NodeJS before but never tried to code in it. Server Script is my first contact with NodeJS and now I'm a big fan of it. I love how simple it is and its async nature. 

But it is with Server Script that I have hit a few roadblocks. Maybe it's only natural since both NodeJS and Server Script are new to me.

Some important scripts are list below, with comments and detailed explanation if needed. 

Scripts for activities table 

// Insert script 
function insert(item, user, request) {
    
    if (!item.when) {
        item.when = new Date();
    }
    item.userId = user.userId;
    request.execute();

} 

//   Read (aka query) script
function read(query, user, request) {
    
    // Add a additional query clause to ensure that user only gets to see their own activities
    query.where({userId: user.userId});
    request.execute();

}

Scripts for devices table 

// Insert script
function insert(item, user, request) {
    // we don't trust the client, we always set the user on the server
    item.userId = user.userId;
    // require an installationId
    if (!item.installationId || item.installationId.length === 0) {
        request.respond(400, "installationId is required");
        return;
    }
    // find any records that match this device already (user and installationId combo)
    var devices = tables.getTable('devices');
    devices.where({
        userId: item.userId,
        installationId: item.installationId
    }).read({
        success: function (results) {
            if (results.length > 0) {
                // This device already exists, so don't insert the new entry,
                // update the channelUri (if it's different)
                if (item.channelUri === results[0].channelUri) {
                    request.respond(200, results[0]);
                    return;
                }
                // otherwise, update the notification id 
                results[0].channelUri = item.channelUri;
                devices.update(results[0], {
                    success: function () {
                        request.respond(200, results[0]);
                        return;
                    }
                });
            }
            else
            {
                request.execute();
            }
        }
    }); 
}

Scripts for groupMembers table 

// Insert script 
var groups = tables.getTable('groups');
var groupMembers = tables.getTable('groupMembers');

function insert(item, user, context) {
    // Make sure that the user can only add members to the group that they own by querying groups table
    groups.where({ id: item.groupId, userId: user.userId })
        .read({
            success: function (results) {
                if (results.length === 0) {
                    context.respond(400, 
                        'You cannot add member to a group which you do not own');
                    return;
                }
                
                groupMembers.where({ groupId: item.groupId, 
                    memberUserId: item.memberUserId })
                    .read({
                        success: function (results) {
                            if (results.length > 0) {
                                context.respond(400,
                                    'The user is already in the group');
                                return;
                            }
                            
                            item.groupOwnerUserId = user.userId;
                            context.execute();
                        }
                    });
            }
        });
    

} 

//  Delete script
var groupMembers = tables.getTable('groupMembers');

function del(id, user, context) {
    // Make sure that user can only delete member from the group that they own
    groupMembers.where({ id: id, groupOwnerUserId: user.userId })
        .read({ 
            success: function (results) {
                if (results.length === 0) {
                    context.respond(400, 
                        'You can only delete member from the groups you own');
                    return;
                }
                
                context.execute();
            }
         });
}

Scripts for groups table 

// Insert script
var groups = tables.getTable('groups');

function insert(item, user, context) {
    item.userId = user.userId;
    
    // Make sure that the group with the same name does not already exist
    groups.where({ name: item.name, userId: user.userId })
        .read({
            success: function (results) {
                if (results.length > 0) {
                    context.respond(400,
                        'The group with the name "' + item.name + '" already exists');
                        return;
                }
                
                context.execute();
            }
        });
}   

// Delete script
var groups = tables.getTable('groups');

function del(id, user, context) {
    groups.where({ id: id, userId: user.userId }).read({
        success: function (results) {
            if (results.length === 0) {
                context.respond(400, 
                    'You are not authorized to delete groups which you do not own');
                return;
            }
            // delete all members of the group
            var sqlGroupMembers = 'DELETE FROM groupMembers WHERE groupId = ?';
            mssql.query(sqlGroupMembers, [id], {
                error: function (error) {
                    // The query might error if we have no members in that group
                    // which is fine there is nothing to delete in that case
                }
            });
            context.execute();
        }
    })
    

}
 

Scripts for interactions table 

// Insert script 
var relationships = tables.getTable('relationships');
var devices = tables.getTable('devices');
var profiles = tables.getTable('profiles');

function insert(item, user, context) {
    
    item.userId = user.userId;
    
    // Make sure that the user can only interact with their friends
    relationships.where({ fromUserId: user.userId, toUserId: item.toUserId })
        .read({ 
            success: function (results) {
                if (results.length === 0) {
                    context.respond(400, 
                        'You can only interact with your friends');
                    return;
                }
                
                context.execute();
                
                // Notify the friend that's being interacted with
                sendPushNotifications();
            }
         });
    
    function sendPushNotifications() {
        profiles.where({ userId: user.userId })
            .read({
                success: function (profileResults) {
                    var profile = profileResults[0];
                    devices.where({ userId: item.toUserId })
                        .read({
                            success: function (deviceResults) {
                                deviceResults.forEach(function (device) {
                                    push.wns.sendToastImageAndText01(
                                        device.channelUri, {
                                        image1src: profile.imageUrl,
                                        text1: item.message
                                    });
                                });
                            }
                        });
                }
            });
        
    }
}

Scripts for invites table 

This is a tricky one to implement. But the code is pretty clear, so no explanation needed.   

// Insert script 
var invites = tables.getTable('invites');
var devices = tables.getTable('devices');
var profiles = tables.getTable('profiles');
var relationships = tables.getTable('relationships');

function insert(item, user, context) {
    var fromUserId = item.fromUserId, toUserId = item.toUserId;
    var isUsingInviteCode = item.inviteCode !== undefined && item.inviteCode !== null;
    if (fromUserId !== user.userId) {
        context.respond(400, 'You cannot pretend to be another user when you issue an invite');
        return;
    }
    
    if (toUserId === user.userId) {
        context.respond(400, 'You cannot invite yourself');
        return;
    }
    
    if (isUsingInviteCode) {
        // We're using invitation code instead of using Friends Radar
        invites.where({ inviteCode: item.inviteCode, fromUserId: fromUserId})
            .read({ success: checkRedundantInvite });
    }
    else {
        relationships
            .where({ fromUserId: fromUserId, toUserId: toUserId })
            .read({ success: checkRelationship});
    }
    
    function checkRelationship(results) {
        if (results.length > 0) {
            context.respond(400, 'Your friend is already on your radar');
            return;
        }
        
        invites.where({ toUserId: toUserId, fromUserId: fromUserId})
            .read({ success: checkRedundantInvite });
    }
    
    function checkRedundantInvite(results) {
        if (results.length > 0) {
            context.respond(400, 'This user already has a pending invite');
            return;
        }
        
        // Everything checks out, process the invitation
        processInvite();
    }
    
    function processInvite() {
        item.approved = false;
        context.execute({
            success: function(results) {
                context.respond();
                if (isUsingInviteCode === false) {
                    // Send push notification
                    getProfile(results);
                }
            }
        });
    }
    
    function getProfile(results) {
        profiles.where({ userId : user.userId }).read({
            success: function(profileResults) {
                sendNotifications(profileResults[0]);
            }
        });
    }

    function sendNotifications(profile) {
        // Send push notifictions to all devices registered to 
        // the invitee
        devices.where({ userId: item.toUserId }).read({
            success: function (results) {
                results.forEach(function (device) {
                    push.wns.sendToastImageAndText01(device.channelUri, {
                        image1src: profile.imageUrl,
                        text1: 'You have been invited to "Friends Radar" by ' + item.fromUserName
                    }, {
                        succees: function(data) {
                            console.log(data);
                        },
                        error: function (err) {
                            // The notification address for this device has expired, so
                            // remove this device. This may happen routinely as part of
                            // how push notifications work.
                            if (err.statusCode === 403 || err.statusCode === 404) {
                                devices.del(device.id);
                            } else {
                                console.log("Problem sending push notification", err);
                            }
                        }
                    });
                }); 
             }
        });
    }    

} 

// Update script 
var invites = tables.getTable('invites');
var relationships = tables.getTable('relationships');

function update(item, user, context) {
    
    invites.where({ id : item.id }).read({
        success : function (results) {
            if (results[0].toUserId !== user.userId) {
                context.respond(400, 'Only the invitee can accept or reject an invite');
                return;
            }
            processInvite(item);
        }
    });
    
    function processInvite(item) {
        
        if (item.approved) {
            // If an invite is updated and marked approved, that means the 
            // invitee has accepted, so add relationships between the two 
            // users
            relationships.insert({
                fromId: item.fromUserId,
                toUserId: user.userId
            }, { 
                success: deleteInvite
            });
            relationships.insert({
                fromId: user.userId,
                toUserId: item.fromUserId
            });
        } else {
            // If an invite is updated, but approved !== true then we assume the invite
            // is being rejected. An alternative approach would have the client delete
            // the invite directly 
            deleteInvite();
        }
    }
    
    function deleteInvite() {
        // We have taken the necessary action on this invite, 
        // so delete it
        invites.del(item.id, {
            success: function () {
                context.respond(200, item);
            }
        });
    }
}

Scripts for operations table  

The operations table is a  only there for user to do certain operations with their friends. As noted in the comments of the C# model for operations table, there are now only two operations: search and updateLocation. For these two operations, we use push notifications to tell all the friends to either update their location info by sending a pulse to the backend immediately or check to see whether they fit in certain criteria and then send the pulse. Both way, we use the text property of the push notification parameter to store information. This can be viewed as sort of a kind of distributed computation as that all the friends updates their location info to form a search results.

// Insert script
var operationIds = {
        updateLocation: 1,
        search: 2
    };
var devices = tables.getTables('devices');
var relationships = tables.getTables('relationships');
var settings = tables.getTables('settings');

function trimString (str) {
    return str.replace(/^\s*/, "").replace(/\s*$/, "");
}

function isString (obj) {
    return typeof obj === 'string';
}

// Get the search criterias from item or (if not found) from the settings table
function getSearchCriterias (item, userId, callback) {
    var friendName = item.friendName;
    if (friendName && isString(friendName) &&
        trimString(friendName) !== '' ) {
        friendName = trimString(friendName).toLowerCase();
    } else {
        friendName = '';
    }
    var criterias = {
            searchDistance: item.searchDistance,
            friendName: friendName,
            latitude: item.latitude,
            longitude: item.longitude
        };
    if (item.hasOwnProperty('searchDistance')) {
        callback(criterias);
    }
    else {
        settings.where({ userId: userId })
            .read({
                success: function (results) {
                    results.forEach(function (setting) {
                        if (setting.key === 'searchDistance') {
                            criterias.searchDistance = criterias.searchDistance || 
                                parseFloat(setting.value);
                        }
                    });
                    
                    callback(criterias);
                }
            });
    }
}

function insert(item, user, context) {
    item.userId = user.userId;
    if (!item.when) {
        item.when = new Date();
    }
    context.execute();
    relationships.where({user: user.userId})
        .read({
            success: function (friendResults) {
                var operationId = item.id;
                if (operationId === operationIds.search) {
                    // Get search criterias and ask friends to see whether they meet 
                    // the search criterias
                    getSearchCriterias(item, user.userId, function (criterias) {
                        friendResults.forEach(function (friend) {
                            devices.where({ userId: friend.toUserId })
                                .read({
                                    success: function (deviceResults) {
                                        deviceResults.forEach(function (device) {
                                            push.wns.sendToastText04(device.channelUri, {
                                                text1: 'search: ' + 
                                                    'latitude=' + criterias.latitude + ', ' +
                                                    'longitude=' + criterias.longitude + ', ' + 
                                                    'friendName=' + criterias.friendName + 
                                                    'searchDistance=' + criterias.searchDistance
                                            });
                                        });
                                    }
                                });
                        });
                    });
                } 
                else if (operationId === operationIds.updateLocation) {
                    // Simply ask all the friends to update their location info
                    friendResults.forEach(function (friend) {
                            devices.where({ userId: friend.toUserId })
                                .read({
                                    success: function (deviceResults) {
                                        deviceResults.forEach(function (device) {
                                            push.wns.sendToastText04(device.channelUri, {
                                                text1: 'updateLocation'
                                            });
                                        });
                                    }
                                });
                        });
                }
            }
        });

}

Scripts for profiles  table 

// Insert script
var profiles = tables.getTable('profiles');
var settings = tables.getTable('settings');


function populateDefaultSettings (userId) {
    var sql = 'INSERT INTO settings ' + 
            "SELECT '" + userId + "', key, value from settings " + 
            "WHERE userId = 'defaultSettingUser'";
    mssql.query(sql, [], {
        error: function () {
            console.log('Error populating default settings for user with id "' + 
                userId + '"');
        }
    });
}

function insert(item, user, context) {

    if (!item.name && item.name.length === 0) {
        context.respond(400, 'A name must be provided');
        return;
    }

    if (item.userId !== user.userId) {
        context.respond(400, 'A user can only insert a profile for their own userId.');
        return;
    }

    // Check if a user with the same userId already exists
    profiles.where({ userId: item.userId }).read({
        success: function (results) {
            if (results.length > 0) {
                context.respond(400, 'Profile already exists.');
                return;
            }

            // No such user exists, add a timestamp and proces the insert
            item.created = new Date();
            context.execute();
            populateDefaultSettings();
        }
    });
}

Scripts for pulses table  

This is the trickiest one to implement IMO, esp. for the read operation.  Since I need to support search capability with the pulses table, I have to resort to a hack which heavily relies on the knowledge of the inner-workings of the server script. I found this hack by printing out the content/structure of the query object  of the read script.  Through it, I found that the filters (the query criteria) of the query object has a structure for a linq query 
usersTable.Where(userId = 'userA' && name = 'Named');

something like below diagram

So I can traverse the filters object to get the member-value pairs of the query to do my search. This is the biggest roadblock I've hit.  Glad it's removed. The function to do this is  findMemberValuePairsFromExpression.  

Another roadblock is to calculate the distance of two latitude-longitude pairs, but a google search did the trick. The function to implement this is  calcDistanceBetweenTwoLocation.  

The last one that got me thinking is how do I search with a few complex criteria which involves computations. It turns out the where method of query object can take a function predicate to do so. Yayyyyy!!!   

// Insert script 
var devices = tables.getTable('devices');
var profiles = tables.getTable('profiles');
var relationships = tables.getTable('relationships');

function checkUserName (item, userId, callback) {
    if (item.userName) {
        callback();
    }
    profiles.where({userId: userId})
        .read({
            success: function (results) {
                item.userName = results[0].name;
                callback();
            }
        });
}

function insert(item, user, context) {
    item.userId = user.userId;
    if (!item.when) {
        item.when = new Date();
    }
    
    checkUserName(item, user.userId, function () {
        context.execute({
            success: getProfile
        });
    });
    
    function getProfile() {
        context.respond();
        profiles.where({ userId : user.userId }).read({
            success: function(profileResults) {
                sendNotifications(profileResults[0]);
            }
        });
    }
    
    function sendNotifications(profile) {
        relationships.where({fromUserId: user.userId}).read({
            success: function(friends) {
                friends.forEach(function(friend) {
                    // Send push notifications to all devices registered to a friend
		    devices.where({ userId: friend.toUserId }).read({
			success: function (results) {
			    results.forEach(function (device) {
                                push.wns.sendToastImageAndText01(device.channelUri, {
                                    image1src: profile.imageUrl,
                                    text1: 'pulse: from=' + item.userId + ',' + 
                                            'deviceId= ' + device.id + ',' +
                                            'latitude=' + item.latitude + ',' +
                                            'longitude=' + item.longitude
                                }, {
                                    success: function(data) {
                                        console.log(data);
                                    },
                                    error: function (err) {
                                        // The notification address for this device has expired, so
                                        // remove this device. This may happen routinely as part of
                                        // how push notifications work.
                                        if (err.statusCode === 403 || err.statusCode === 404) {
                                            devices.del(device.id);
                                        } else {
                                            console.log("Problem sending push notification", err);
                                        }
                                    }
                                });
                            }); 
                         }
                    });
                });
            }
        });
        
    }
}


// Read script
var relationships = tables.getTable('relationships');
var settings = tables.getTable('settings');
var pulses = tables.getTable('pulses');

function isObject(variable) {
    return variable !== null && 
        variable !== undefined && 
        typeof variable === 'object';
}

// Print an object recursively
function printObject(obj, objName, printer) {
    if (!isObject(obj)) {
        return;
    }
    var prefix = objName === undefined || objName === ''? 
                '' : objName + '.';
    printer = printer || console.log;
    for(var name in obj) {
        if (obj.hasOwnProperty(name)) {
            var prop = obj[name];
            if(isObject(prop)) {
                printObject(prop, prefix + name);
            }
            else {
                var str = prefix + name + ': ' + prop;
                printer(str);
            }
        }
    }
}

// Find all the member-value pairs from the expression object
function findMemberValuePairsFromExpression (expr, ret) {
    if (!isObject(expr)) {
        return null;
    }
    ret = ret || {};
    for (var name in expr) {
        if (expr.hasOwnProperty(name)) {
            var prop = expr[name];
            if (name === 'parent') { // Ignore parent property since it's added by us
                continue;
            }
            else if (name === 'left') { // member expression are in the left subtree
                if (isObject(prop)) {
                    prop.parent = expr;
                    findMemberValuePairsFromExpression(prop, ret);
                }
            }
            else if (name === 'member') {
                // Found a member expression, find the value expression 
                // by the knowledge of the structure of the expression
                var value = expr.parent.right.value;
                ret[prop] = value;
            }
        }
    }
    
    if (expr.parent) {
        // Remove the added parent property
        delete expr.parent;
    }
    
    return ret;
}

function toRad(Value) {
    /** Converts numeric degrees to radians */
    return Value * Math.PI / 180;
}

function calcDistanceBetweenTwoLocation (lat1, lon1, lat2, lon2) {
    //Radius of the earth in:  1.609344 miles,  6371 km  | var R = (6371 / 1.609344);
    var R = 3958.7558657440545; // Radius of earth in Miles 
    var dLat = toRad(lat2-lat1);
    var dLon = toRad(lon2-lon1); 
    var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * 
            Math.sin(dLon/2) * Math.sin(dLon/2); 
    var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
    var d = R * c;
    return d * 1000; // Translate to metre
}

// Get the filters component from query object and 
// find the member-value pairs in it
function findMemberValuePairsFromQuery (query) {
    var filters = query.getComponents().filters;
    return findMemberValuePairsFromExpression(filters);
}

// Get the search criterias from filter expression or (if not found) from the settings table
function getSearchCriterias (filterExpression, userId, callback) {
    var criterias = {
            searchDistance: filterExpression.searchDistance,
            searchTimeTolerance: filterExpression.searchTimeTolerance,
            friendName: filterExpression.friendName
        };
    if (filterExpression.hasOwnProperty('searchDistance') &&
        filterExpression.hasOwnProperty('searchTimeTolerance')) {
        callback(criterias);
    }
    else {
        settings.where({ userId: userId })
            .read({
                success: function (results) {
                    results.forEach(function (setting) {
                        if (setting.key === 'searchDistance' || 
                            setting.key === 'searchTimeTolerance') {
                            criterias[setting.key] = criterias[setting.key] || 
                                parseFloat(setting.value);
                        }
                    });
                    
                    callback(criterias);
                }
            });
    }
} 

function trimString (str) {
    return str.replace(/^\s*/, "").replace(/\s*$/, "");
}

function isString (obj) {
    return typeof obj === 'string';
}

function read(query, user, context) {
    
    relationships
        .where({ fromUserId: user.userId })
        .select('toUserId')
        .read({
            success: function (results) {
                var filterExpression = findMemberValuePairsFromQuery(query);
                if (filterExpression.hasOwnProperty('latitude') && 
                    filterExpression.hasOwnProperty('longitude')) {
                    // This is a search operation   
                    var lat = filterExpression.latitide, lon = filterExpression.longitude;
                    getSearchCriterias(filterExpression, user.userId, function (criterias) {
                        /* Do the search by filtering with the search criteria */
                        var searchTimeToleranceAsMilliseconds = criterias.searchTimeTolerance * 1000;
                        var timestampAsMilliseconds = new Date().getTime() - 
                                searchTimeToleranceAsMilliseconds;
                        var timestamp = new Date(timestampAsMilliseconds);
                        var friendName = criterias.friendName;
                        if (isString(friendName) &&
                            trimString(friendName) !== '') {
                            friendName = trimString(friendName).toLowerCase();        
                        } else {
                            friendName = null;
                        }
                        
                        // Query with a function to do the search
                        pulses.where(function (friends) {
                                var isAFriend = this.userId in friends;
                                if (!isAFriend) {
                                    return false;
                                }
                                if (friendName && 
                                    trimString(this.friendName)
                                    .toLowerCase().indexOf(friendName) === -1) {
                                    return false;
                                }
                                var distance = calcDistanceBetweenTwoLocation(
                                    this.latitude, this.longitude, lat, lon);
                                
                                return distance <= criterias.searchDistance && 
                                    this.when >= timestamp;
                            }, results)
                            .read({
                                success: function (searchResults) {
                                    context.respond(200, searchResults);
                                }
                            });
                    });
                }
                else {
                    // Attach an extra clause to the user's query 
                    // that forces it to look only in users the user
                    // is a friend of
                    query.where(function (friends) {
                        return this.userId in friends;
                    }, results);
                    context.execute();
                }
                
            }
        });
    

}

Other scripts

Other Server Scripts are relatively simple, so I omit them here. 

Links

For learning Server Script, there are two links I think can be helpful 

 [1] Mobile Services server script reference   

 [2] TypeScript declaration file for Windows Azure Mobile Services server scripts   

Push Me, Please!     

Windows Notification Services coupled with the Server Script feature of Windows Azure Mobile Services can take care of your push notification needs beautifully. Just follow below links: 

[1] Get started with push notifications in Mobile Services for Windows Store

[2] Push notifications to users by using Mobile Services for Windows Store  

I got the basics working in one hour with most of time spent on configuring stuff. So it's not hard, at all. 

What to Notify?  

Currently, only when a friend approaches you, or invites you, that you'll get toast notifications. In the future, you will also be notified when the friend leaves the defined distance range. And you can send a goodbye note to them.

Making a Gesture  

I'm still working on this part. Trying to figure out what kind of gesture should the app support. If you have any suggestions, write in the comments section. 

Radar Screen 

When searching for friends nearby, radar screen shows up. I know this is a metaphor, but without a real radar screen in action, the name Friends Radar seems, well, nameless. 

Animation  

A radar screen is not proper without smooth rotating animation. Although I'm no deadmau5 who can animate the whole building, I'm going to make a metrofied radar screen scanning the sky for good friends. 

Map Overlay 

No developer in their rightful mind dare call their app location-based if no map is used. So a map will be overlaid on top of the radar screen with information of spotted friends layered on top of the map. Just as expected. 

Semantic Zoom 

This is a bonus feature, probably in the second version I'll get this to work with the radar screen. The basic idea is to let user see more info about the friends when they zoom in and less when zoom out. 

Live Tile 

One thing of what's special about Windows Store (and Windows Phone) apps,  is Live Tile. And  for Friends Radar, Live Tile is going to show how many friends are nearby.  It's that simple.   

Search and Lock Screen Integration 

User should be able to use the search charm to find friends near them like below 

And through lock screen integration, a user gets to see how many friends they have around them by a glance. It's called glancibility. 

App Settings  

This is of course important. You have to allow user to customize your app. In the case of Friends Radar, user can manage friends, set the default search distance range and the frequency of location broadcasting
as well as specifying whether to integrate with lock screen, etc. 

Points of Interest 

Windows 8 is going to be big and location is going to big if not bigger. With that, behold Friends Radar! 

History 

  • 2012/10/23 - First edition
  • 2012/10/23 - Minor fixes
  • 2012/10/26 - Added Live Tile section 
  • 2012/11/22 - Added backend implementation, Grouping and Friends Interaction section. Fixed a few typos  
  • 2012/11/23 - Added "What to Notify" section and updated "Pick on Your Friends" and "Scripts for operations table" subsections to make certain things clearer 
  • 2012/11/25 - Fixed the broken format 
 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

About the Author

imgen
Software Developer (Senior)
United States United States
A living coder, a music lover, a Ancient-Chinese poem writer, a food hater - that's right, I hate food, it's the one single biggest annoyance of life if you ask me
Follow on   Twitter

Comments and Discussions

 
QuestionFantastic idea. PinadminChris Maunder23-Oct-12 16:31 
AnswerRe: Fantastic idea. Pinmemberimgen23-Oct-12 16:44 
AnswerRe: Fantastic idea. Pinmemberimgen23-Oct-12 17:09 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web03 | 2.8.140709.1 | Last Updated 26 Nov 2012
Article Copyright 2012 by imgen
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid