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;
locator.PositionChanged += (sender, e) =>
{
Geoposition position = e.Position;
App.LastKnownLocation = position; };
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:
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();
var isGroup = itemType.Name == "Group`1" &&
itemType.Namespace == "NogginBox.WinRT.Extra.Collections";
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
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:
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
[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; }
[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; }
[DataMember(Name = "address")]
public string Address { get; set; }
}
[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; }
}
[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;
}
[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; }
}
[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; }
[DataMember(Name = "inviteCode")]
public string InviteCode { get; set; }
[DataMember(Name = "approved")]
public bool Approved { get; set; }
}
[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; }
[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; }
[DataMember(Name = "address")]
public string Address { get; set; }
}
[DataTable(Name = "operations")]
public class Operation
{
[DataMember(Name = "id")]
public int Id { get; set; }
[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
}
[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; }
}
[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; }
[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; }
[DataMember(Name = "searchTimeTolerance")]
public int SearchTimeTolerance { get; set; }
[DataMember(Name = "friendName")]
public string FriendName { get; set; }
#endregion
}
[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; }
}
[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
function insert(item, user, request) {
if (!item.when) {
item.when = new Date();
}
item.userId = user.userId;
request.execute();
}
function read(query, user, request) {
query.where({userId: user.userId});
request.execute();
}
Scripts for devices
table
function insert(item, user, request) {
item.userId = user.userId;
if (!item.installationId || item.installationId.length === 0) {
request.respond(400, "installationId is required");
return;
}
var devices = tables.getTable('devices');
devices.where({
userId: item.userId,
installationId: item.installationId
}).read({
success: function (results) {
if (results.length > 0) {
if (item.channelUri === results[0].channelUri) {
request.respond(200, results[0]);
return;
}
results[0].channelUri = item.channelUri;
devices.update(results[0], {
success: function () {
request.respond(200, results[0]);
return;
}
});
}
else
{
request.execute();
}
}
});
}
Scripts for groupMembers
table
var groups = tables.getTable('groups');
var groupMembers = tables.getTable('groupMembers');
function insert(item, user, context) {
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();
}
});
}
});
}
var groupMembers = tables.getTable('groupMembers');
function del(id, user, context) {
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
var groups = tables.getTable('groups');
function insert(item, user, context) {
item.userId = user.userId;
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();
}
});
}
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;
}
var sqlGroupMembers = 'DELETE FROM groupMembers WHERE groupId = ?';
mssql.query(sqlGroupMembers, [id], {
error: function (error) {
}
});
context.execute();
}
})
}
Scripts for interactions
table
var relationships = tables.getTable('relationships');
var devices = tables.getTable('devices');
var profiles = tables.getTable('profiles');
function insert(item, user, context) {
item.userId = user.userId;
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();
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.
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) {
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;
}
processInvite();
}
function processInvite() {
item.approved = false;
context.execute({
success: function(results) {
context.respond();
if (isUsingInviteCode === false) {
getProfile(results);
}
}
});
}
function getProfile(results) {
profiles.where({ userId : user.userId }).read({
success: function(profileResults) {
sendNotifications(profileResults[0]);
}
});
}
function sendNotifications(profile) {
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) {
if (err.statusCode === 403 || err.statusCode === 404) {
devices.del(device.id);
} else {
console.log("Problem sending push notification", err);
}
}
});
});
}
});
}
}
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) {
relationships.insert({
fromId: item.fromUserId,
toUserId: user.userId
}, {
success: deleteInvite
});
relationships.insert({
fromId: user.userId,
toUserId: item.fromUserId
});
} else {
deleteInvite();
}
}
function deleteInvite() {
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.
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';
}
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) {
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) {
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
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;
}
profiles.where({ userId: item.userId }).read({
success: function (results) {
if (results.length > 0) {
context.respond(400, 'Profile already exists.');
return;
}
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!!!
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) {
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) {
if (err.statusCode === 403 || err.statusCode === 404) {
devices.del(device.id);
} else {
console.log("Problem sending push notification", err);
}
}
});
});
}
});
});
}
});
}
}
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';
}
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);
}
}
}
}
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') { continue;
}
else if (name === 'left') { if (isObject(prop)) {
prop.parent = expr;
findMemberValuePairsFromExpression(prop, ret);
}
}
else if (name === 'member') {
var value = expr.parent.right.value;
ret[prop] = value;
}
}
}
if (expr.parent) {
delete expr.parent;
}
return ret;
}
function toRad(Value) {
return Value * Math.PI / 180;
}
function calcDistanceBetweenTwoLocation (lat1, lon1, lat2, lon2) {
var R = 3958.7558657440545; 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; }
function findMemberValuePairsFromQuery (query) {
var filters = query.getComponents().filters;
return findMemberValuePairsFromExpression(filters);
}
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')) {
var lat = filterExpression.latitide, lon = filterExpression.longitude;
getSearchCriterias(filterExpression, user.userId, function (criterias) {
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;
}
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 {
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