luma_third_party: Logcats, Apps, CrowdUI, Fixes

Users will no longer need to launch or kill apps.
During token generation, app id's are included.
Once authenticated, the adb service launches and
kills the app, as well as records relevant logcats
for the user's app session. Multiple device support
is improved in this CL, where each device has its
own layout capture queue. The OpenSTF device UI is
transformed into a crowd worker UI.

Change-Id: I9fe7255b6f085871e835ef45a6999583045ffed5
diff --git a/crowdstf/.gitignore b/crowdstf/.gitignore
index 1366020..cb999ae 100644
--- a/crowdstf/.gitignore
+++ b/crowdstf/.gitignore
@@ -12,3 +12,4 @@
 /tmp/
 .eslintcache
 /screen_shots/
+config.json
diff --git a/crowdstf/config.js b/crowdstf/config.js
new file mode 100644
index 0000000..6c51480
--- /dev/null
+++ b/crowdstf/config.js
@@ -0,0 +1,14 @@
+var fs = require('fs');
+var logger = require('./lib/util/logger');
+var log = logger.createLogger('config');
+
+var config;
+try {
+  config = JSON.parse(fs.readFileSync(__dirname + '/config.json', 'utf8'));
+  log.info('Found config.json:\n %s', JSON.stringify(config, null, 2));
+} catch (ignored) {
+  config = {};
+  log.warn('No config file found, using defaults.');
+}
+
+module.exports = config;
diff --git a/crowdstf/lib/db/api.js b/crowdstf/lib/db/api.js
index 8bdcf8f..1189dec 100644
--- a/crowdstf/lib/db/api.js
+++ b/crowdstf/lib/db/api.js
@@ -6,8 +6,14 @@
 
 var dbapi = Object.create(null)
 
+const MIN_TOKEN_LEN = 20;
+
 db.connect().then(function(conn) {
   r.table('kicks').changes().run(conn, function(err, cursor) {
+    if (!cursor) {
+      return;
+    }
+
     cursor.each(function(err, item) {
       if (dbapi.kickCallback) {
         dbapi.kickCallback(item.new_val.serial);
@@ -158,7 +164,7 @@
 /**
  * Persists android device events
  * @example
- *   dbapi.saveDeviceEvent("foo", "bar"....)
+ *   dbapi.saveDeviceEvent(eventObj)
  *   .then(function(result) {
  *     console.log("Saved", result.inserted, "device events");
  *   })
@@ -167,31 +173,11 @@
  *   })
  * @returns {Promise} Returns a promise with RethinkDB result
  */
-dbapi.saveDeviceEvent = function(deviceSerial, sessionId, eventName, imgId,
-    timestamp, seq, contact, x, y, pressure, userEmail, userGroup, userIP,
-    userLastLogin, userName, viewHierarchy) {
-  var deviceEventDTO = {
-    serial: deviceSerial,
-    sessionId: sessionId,
-    eventName: eventName,
-    imgId: imgId,
-    timestamp: timestamp,
-    seq: seq === undefined ? null : seq,
-    x: x === undefined ? null : x,
-    y: y === undefined ? null : y,
-    pressure: (pressure === undefined ? null : pressure),
-    userEmail: userEmail,
-    userGroup: userGroup,
-    userIP: userIP,
-    userLastLogin: userLastLogin,
-    userName: userName,
-    viewHierarchy: viewHierarchy ? viewHierarchy : ''
-  };
-
-  return db.run(r.table('deviceEvents').insert(deviceEventDTO, {
+dbapi.saveDeviceEvent = function(deviceEvent) {
+  return db.run(r.table('deviceEvents').insert(deviceEvent, {
     durability: 'soft'
-  }))
-}
+  }));
+};
 
 dbapi.saveDeviceInitialState = function(serial, device) {
   var data = {
@@ -222,7 +208,23 @@
   }))
 }
 
-dbapi.getDeviceOwner = function(serial) {
+dbapi.saveLogcat = function(serial, date, logcatMessage) {
+  db.run(r.table('devices').get(serial)).then(function(device) {
+    if (device && device.owner && device.owner.email) {
+      var token = device.owner.email;
+      if (token && token.length > MIN_TOKEN_LEN) {
+        return db.run(r.table('tokenLogcats').insert({
+          token: token,
+          logcatMessage: logcatMessage,
+          logcatDate: date,
+          timestamp: new Date().getTime()
+        }));
+      }
+    }
+  });
+};
+
+dbapi.getDeviceBySerial = function(serial) {
   return db.run(r.table('devices').get(serial));
 };
 
@@ -397,15 +399,44 @@
   return db.run(r.table('tokens').get(token));
 };
 
+dbapi.getAppIdBySerial = function(serial, callback) {
+  db.run(r.table('tokens').filter({
+      serial: serial
+    }
+  ).orderBy(r.desc('creationTime')).limit(1)).then(function(cursor) {
+    cursor.toArray(function(err, results) {
+      if (err || !results.length) {
+        return callback('Error fetching app from serial.');
+      }
+
+      callback(null, results[0].appId);
+    });
+  });
+};
+
 dbapi.updateToken = function(tokenObj) {
   return db.run(r.table('tokens').get(tokenObj.token).update(tokenObj));
 };
 
 dbapi.expireToken = function(token) {
-  return db.run(r.table('tokens').get(token).update({
-    status: "expired",
-    expiredTime: Date.now()
-  }));
+  var serial;
+
+  return dbapi.getToken(token).then(function(tokenObj) {
+    if (tokenObj.status !== 'expired') {
+      serial = tokenObj.serial;
+
+      return db.run(r.table('tokens').get(token).update({
+        status: "expired",
+        expiredTime: Date.now()
+      }));
+    }
+  }).then(function() {
+    return dbapi.deleteUser(token);
+  }).then(function() {
+    return dbapi.publishKickedSerial(serial);
+  }).then(function() {
+    return dbapi.unsetDeviceOwner(serial);
+  });
 };
 
 module.exports = dbapi
diff --git a/crowdstf/lib/db/tables.js b/crowdstf/lib/db/tables.js
index 46bbfe3..82952bb 100644
--- a/crowdstf/lib/db/tables.js
+++ b/crowdstf/lib/db/tables.js
@@ -63,4 +63,10 @@
 , kicks: {
     primaryKey: 'id'
   }
+, tokenLogcats: {
+    primaryKey: 'id'
+  }
+, deviceApps: {
+    primaryKey: 'appId'
+  }
 }
diff --git a/crowdstf/lib/units/app/index.js b/crowdstf/lib/units/app/index.js
index db71397..afaa970 100644
--- a/crowdstf/lib/units/app/index.js
+++ b/crowdstf/lib/units/app/index.js
@@ -16,6 +16,7 @@
 var pathutil = require('../../util/pathutil')
 var dbapi = require('../../db/api')
 var datautil = require('../../util/datautil')
+var config = require('../../../config');
 
 var auth = require('./middleware/auth')
 var deviceIconMiddleware = require('./middleware/device-icons')
@@ -77,6 +78,10 @@
   , keys: [options.secret]
   }))
 
+  app.get('/task-end', function(req, res) {
+    res.render('taskend', {contactEmail: config.hitAccounts.contactEmail});
+  });
+
   app.use(auth({
     secret: options.secret
   , authUrl: options.authUrl
@@ -89,6 +94,37 @@
     res.send('OK')
   })
 
+  var expireToken = function(token, res) {
+    dbapi.expireToken(token).then(function() {
+      res.sendStatus(200);
+    }).catch(function(err) {
+      log.error('Error expiring token: ', err.stack);
+      res.sendStatus(500);
+    });
+  };
+
+  app.delete('/app/api/v1/token/:token', function(req, res) {
+    var token = req.params.token;
+
+    expireToken(token, res);
+  });
+
+  app.delete('/app/api/v1/token', function(req, res) {
+    var serial = req.query.serial;
+
+    dbapi.getDeviceBySerial(serial).then(function(device) {
+      if (device && device.owner && device.owner.email) {
+        expireToken(device.owner.email, res);
+      } else {
+        res.status(404);
+        res.send('No owner found for serial: ' + serial);
+      }
+    }).catch(function(err) {
+      log.error('Failed to get device by serial: ', err.stack);
+      res.sendStatus(500);
+    });
+  });
+
   app.use(bodyParser.json())
   app.use(csrf())
   app.use(validator())
@@ -99,7 +135,7 @@
   })
 
   app.get('/', function(req, res) {
-    res.render('index')
+    res.render('index', {stfConfig: JSON.stringify(config || {})});
   })
 
   app.get('/app/api/v1/state.js', function(req, res) {
@@ -153,28 +189,6 @@
       })
   })
 
-  app.delete('/app/api/v1/token/:token', function(req, res) {
-    var token = req.params.token;
-
-    dbapi.getToken(token).then(function(tokenObj) {
-      if (tokenObj.status === 'expired') {
-        return res.send(200);
-      }
-
-      var serial = tokenObj.serial;
-
-      dbapi.deleteUser(token).then(function() {
-        dbapi.expireToken(token).then(function() {
-          dbapi.publishKickedSerial(serial).then(function() {
-            dbapi.unsetDeviceOwner(serial).then(function() {
-              res.send(200);
-            });
-          });
-        });
-      });
-    });
-  });
-
   app.get('/app/api/v1/devices', function(req, res) {
     dbapi.loadDevices()
       .then(function(cursor) {
@@ -245,6 +259,26 @@
       })
   })
 
+  // Tilde pattern is REST shorthand for "my", e.g. get my token.
+  app.get('/app/api/v1/token/~', function(req, res) {
+    if (!req.user || !req.user.email) {
+      res.status(404);
+      return res.send('Missing user email in request.');
+    }
+
+    dbapi.getToken(req.user.email).then(function(tokenObj) {
+      if (tokenObj) {
+        res.json(tokenObj);
+      } else {
+        res.status(404);
+        res.send('Token not found for email: ' + req.user.email);
+      }
+    }).catch(function(err) {
+      log.error('Failed to get token: ', err.stack);
+      res.sendStatus(500);
+    });
+  });
+
   server.listen(options.port)
   log.info('Listening on port %d', options.port)
 }
diff --git a/crowdstf/lib/units/app/middleware/auth.js b/crowdstf/lib/units/app/middleware/auth.js
index 02e3238..81c3871 100644
--- a/crowdstf/lib/units/app/middleware/auth.js
+++ b/crowdstf/lib/units/app/middleware/auth.js
@@ -46,6 +46,18 @@
         })
         .catch(next)
     }
+    // Authenticate mock sessions in development.
+    else if (req.query.authed) {
+      req.user = {
+        name: 'auth',
+        email: 'auth',
+        ip: 'auth'
+      };
+
+      req.session.jwt = req.user;
+
+      next();
+    }
     else {
       // No session, forward to auth client
       res.redirect(options.authUrl)
diff --git a/crowdstf/lib/units/auth/token.js b/crowdstf/lib/units/auth/token.js
index 95d5026..8b63aee 100644
--- a/crowdstf/lib/units/auth/token.js
+++ b/crowdstf/lib/units/auth/token.js
@@ -73,14 +73,15 @@
     app.use(basicAuthMiddleware);
   }
 
-  app.get('/auth/token/', function(req, res) {
+  var resetSession = function(req, res) {
     res.clearCookie('XSRF-TOKEN');
     res.clearCookie('ssid');
     res.clearCookie('ssid.sig');
-    res.status(401);
-    res.send('401 Unauthorized, your auth token may have been expired.');
-  });
+    res.redirect('/task-end');
+  };
 
+  app.get('/auth/token', resetSession);
+  app.get('/auth/token/', resetSession);
   app.get('/auth/token/:token', function(req, res) {
     res.clearCookie('XSRF-TOKEN');
     res.clearCookie('ssid');
@@ -89,7 +90,7 @@
     if (token) {
       // Check if token is in db and is valid.
       dbapi.getToken(token).then(function(tokenObj) {
-        if (tokenObj) {
+        if (tokenObj && tokenObj.status === 'unused') {
           var log = logger.createLogger('auth-token');
           log.setLocalIdentifier(req.ip);
 
@@ -126,11 +127,12 @@
           setTimeout(function() {
             console.log('Kicking user, reached timeout for token',
                         tokenObj.token);
-            request(options.appUrl + '/app/api/v1/expireToken/' +
+            request.delete(options.appUrl + '/app/api/v1/token/' +
                     tokenObj.token + '?authed=true',
-                    function(error, response, body) {
-                      if (error) {
-                        throw error;
+                    function(err, res) {
+                      if (err) {
+                        log.error('Error kicking user token ', tokenObj.token,
+                            err.stack);
                       }
                     });
           }, tokenObj.expireMinutes * MILLIS_IN_ONE_MINUTE);
@@ -143,15 +145,11 @@
           res.redirect(authRedirURL);
         }
         else {
-          return res.json(401, {
-            success: false
-          });
+          return resetSession(req, res);
         }
       }).catch(function(err) {
         log.error('Failed to load token "%s": ', token, err.stack);
-        return res.json(500, {
-          success: false
-        });
+        return res.redirect('/task-end');
       });
     } else {
       return res.status(400).json({
diff --git a/crowdstf/lib/units/device/plugins/group.js b/crowdstf/lib/units/device/plugins/group.js
index 50e62b3..a239ebe 100644
--- a/crowdstf/lib/units/device/plugins/group.js
+++ b/crowdstf/lib/units/device/plugins/group.js
@@ -31,40 +31,41 @@
     })
 
     plugin.join = function(newGroup, timeout, identifier) {
-      return plugin.get()
-        .then(function() {
-          if (currentGroup.group !== newGroup.group) {
-            throw new grouputil.AlreadyGroupedError()
-          }
+      var joinNewGroup = function() {
+        currentGroup = newGroup;
 
-          return currentGroup
-        })
-        .catch(grouputil.NoGroupError, function() {
-          currentGroup = newGroup
+        log.important('Now owned by "%s".', currentGroup.email);
+        log.info('Subscribing to group channel "%s".', currentGroup.group);
 
-          log.important('Now owned by "%s"', currentGroup.email)
-          log.info('Subscribing to group channel "%s"', currentGroup.group)
+        channels.register(currentGroup.group, {
+          timeout: timeout || options.groupTimeout,
+          alias: solo.channel
+        });
 
-          channels.register(currentGroup.group, {
-            timeout: timeout || options.groupTimeout
-          , alias: solo.channel
-          })
+        sub.subscribe(currentGroup.group);
 
-          sub.subscribe(currentGroup.group)
+        push.send([
+          wireutil.global,
+          wireutil.envelope(new wire.JoinGroupMessage(
+              options.serial,
+              currentGroup))
+        ]);
 
-          push.send([
-            wireutil.global
-          , wireutil.envelope(new wire.JoinGroupMessage(
-              options.serial
-            , currentGroup
-            ))
-          ])
+        plugin.emit('join', currentGroup, identifier);
 
-          plugin.emit('join', currentGroup, identifier)
+        return currentGroup;
+      };
 
-          return currentGroup
-        })
-    }
+      return plugin.get().then(function() {
+        if (currentGroup.group !== newGroup.group) {
+          return plugin.leave('kick').then(function() {
+            return joinNewGroup();
+          });
+        }
+
+        return currentGroup;
+      }).catch(grouputil.NoGroupError, joinNewGroup);
+    };
 
     plugin.keepalive = function() {
       if (currentGroup) {
diff --git a/crowdstf/lib/units/device/plugins/logcat.js b/crowdstf/lib/units/device/plugins/logcat.js
index 3645110..5edc110 100644
--- a/crowdstf/lib/units/device/plugins/logcat.js
+++ b/crowdstf/lib/units/device/plugins/logcat.js
@@ -17,49 +17,78 @@
     var plugin = Object.create(null)
     var activeLogcat = null
 
-    var openLogcat = function(serial) {
+    var openLogcat = function(serial, appId) {
       return new Promise(function(resolve, reject) {
-        log.info('adb logcat opening stream.');
-
-        // Flag -c clears backlogged output.
-        // Flag -s specifies the device serial.
-        // Option 2>/dev/null ignores process err messages.
-        var psClear = spawn('adb', [
-          '-s',
-          options.serial,
-          'logcat',
-          '-c',
-          '2>/dev/null'
-        ]);
-
-        psClear.on('close', function(exitCode) {
+        var launchLogcat = function(exitCode) {
           if (exitCode === 0) {
-            // Flag *:D provide info level Debug and higher.
-            // Other flags include
-            // (V)erbose (D)ebug (I)nfo (W)arning (E)rror (F)atal.
-            var logcat = spawn('adb', ['-s',
-              serial,
+            log.info('adb logcat opening stream.');
+
+            // Flag -c clears backlogged output.
+            // Flag -s specifies the device serial.
+            // Option 2>/dev/null ignores process err messages.
+            var psClear = spawn('adb', [
+              '-s',
+              options.serial,
               'logcat',
-              '*:D',
+              '-c',
               '2>/dev/null'
             ]);
 
-            resolve(logcat);
+            psClear.on('close', function(exitCode) {
+              if (exitCode === 0) {
+                // Flag *:D provide info level Debug and higher.
+                // Other flags include
+                // (V)erbose (D)ebug (I)nfo (W)arning (E)rror (F)atal.
+                var logcat = spawn('adb', ['-s',
+                  serial,
+                  'logcat',
+                  '*:D',
+                  '2>/dev/null'
+                ]);
+
+                resolve(logcat);
+              } else {
+                reject();
+                throw Error('Unable to access adb logcat clear process');
+              }
+            });
           } else {
             reject();
-            throw Error('Unable to access adb logcat clear process');
+            throw Error('Unable to access adb app launch process');
           }
-        });
+        };
+
+        if (appId) {
+          log.info('adb launching app %s.', appId);
+          var psAppLaunch = spawn('adb', [
+            '-s',
+            options.serial,
+            'shell',
+            'monkey',
+            '-p',
+            appId,
+            '-c',
+            'android.intent.category.LAUNCHER',
+            '1',
+            '2>/dev/null'
+          ]);
+
+          psAppLaunch.on('close', launchLogcat);
+        } else {
+          launchLogcat(0);
+        }
+      }).catch(function(err) {
+        log.error('Cannot get next free app to launch.', err.stack);
       });
     };
 
-    plugin.start = function(filters) {
+    plugin.start = function(filters, appId) {
       return group.get()
         .then(function(group) {
           return plugin.stop()
             .then(function() {
               log.info('Starting logcat')
-              return openLogcat(options.serial);
+              return openLogcat(options.serial, appId);
             })
             .then(function(logcat) {
               activeLogcat = logcat;
@@ -71,10 +100,6 @@
                     wireutil.envelope(new wire.DeviceLogcatEntryMessage(
                       options.serial,
                       new Date().getTime(),
-                      0,
-                      0,
-                      '',
-                      '',
                       entry.toString()
                     ))
                   ]);
@@ -89,8 +114,30 @@
         });
     };
 
-    plugin.stop = Promise.method(function() {
+    plugin.stop = Promise.method(function(appId) {
       if (plugin.isRunning()) {
+        if (appId) {
+          log.info('adb killing app %s', appId);
+          var arrArgs = [
+            '-s',
+            options.serial,
+            'shell',
+            'am',
+            'force-stop',
+            appId,
+            '2>/dev/null'
+          ];
+
+          var psKill = spawn('adb', arrArgs);
+          psKill.on('close', function(exitCode) {
+            if (exitCode === 0) {
+              log.info('adb killed app %s', appId);
+            } else {
+              log.error('adb error killing app %s', appId);
+            }
+          });
+        }
+
         log.info('Stopping logcat')
         activeLogcat.kill();
         activeLogcat = null
@@ -111,7 +158,7 @@
     router
       .on(wire.LogcatStartMessage, function(channel, message) {
         var reply = wireutil.reply(options.serial)
-        plugin.start(message.filters)
+        plugin.start(message.filters, message.appId)
           .then(function() {
             push.send([
               channel
@@ -143,9 +190,9 @@
             ])
           })
       })
-      .on(wire.LogcatStopMessage, function(channel) {
+      .on(wire.LogcatStopMessage, function(channel, message) {
         var reply = wireutil.reply(options.serial)
-        plugin.stop()
+        plugin.stop(message.appId)
           .then(function() {
             push.send([
               channel
diff --git a/crowdstf/lib/units/device/plugins/screen/stream.js b/crowdstf/lib/units/device/plugins/screen/stream.js
index 3e4c1fa..1ea9c57 100644
--- a/crowdstf/lib/units/device/plugins/screen/stream.js
+++ b/crowdstf/lib/units/device/plugins/screen/stream.js
@@ -19,8 +19,8 @@
 var RiskyStream = require('../../../../util/riskystream')
 var FailCounter = require('../../../../util/failcounter')
 
-const FINAL_DEVICE_CAPTURE_WIDTH = 1080;
-const FINAL_DEVICE_CAPTURE_HEIGHT = 1920;
+const FINAL_DEVICE_CAPTURE_WIDTH = 1080 / 2;
+const FINAL_DEVICE_CAPTURE_HEIGHT = 1920 / 2;
 
 module.exports = syrup.serial()
   .dependency(require('../../support/adb'))
diff --git a/crowdstf/lib/units/device/plugins/screen/util/framestore.js b/crowdstf/lib/units/device/plugins/screen/util/framestore.js
index 0bf4296..c848e61 100644
--- a/crowdstf/lib/units/device/plugins/screen/util/framestore.js
+++ b/crowdstf/lib/units/device/plugins/screen/util/framestore.js
@@ -1,10 +1,12 @@
-var util = require('util')
-var path = require('path')
-var fs = require('fs')
-var mkdirp = require('mkdirp')
+var util = require('util');
+var path = require('path');
+var fs = require('fs');
+var mkdirp = require('mkdirp');
 
-var FINAL_SCREEN_SHOT_DIR = path.join(appRoot, "/../screen_shots/")
-mkdirp.sync(FINAL_SCREEN_SHOT_DIR)
+// Default to the global app root, otherwise fallback on the cwd.
+var FINAL_SCREEN_SHOT_DIR = path.join(global.appRoot || '.',
+    '/../screen_shots/');
+mkdirp.sync(FINAL_SCREEN_SHOT_DIR);
 
 function FrameStore() {
   this.sessionImgCountMap = {};
@@ -19,8 +21,8 @@
 
   var sessionImgNumber = this.sessionImgCountMap[sessionId];
   var fileName = util.format('%s_%s_%d.jpg', sessionId, sessionImgNumber,
-    Date.now());
-  var filePath = path.join(FINAL_SCREEN_SHOT_DIR, fileName)
+      Date.now());
+  var filePath = path.join(FINAL_SCREEN_SHOT_DIR, fileName);
 
   fs.writeFile(filePath, frame, function(err) {
     if (err) {
@@ -29,6 +31,6 @@
   });
 
   return fileName;
-}
+};
 
-module.exports = FrameStore
+module.exports = FrameStore;
diff --git a/crowdstf/lib/units/device/plugins/util/data.js b/crowdstf/lib/units/device/plugins/util/data.js
index a0aa6a6..d9bf888 100644
--- a/crowdstf/lib/units/device/plugins/util/data.js
+++ b/crowdstf/lib/units/device/plugins/util/data.js
@@ -1,5 +1,7 @@
 var syrup = require('stf-syrup')
 var deviceData = require('stf-device-db')
+var config = require('../../../../../config');
+var _ = require('underscore');
 
 var logger = require('../../../../util/logger')
 
@@ -10,6 +12,17 @@
 
     function find() {
       var data = deviceData.find(identity)
+
+      if (config.deviceModels) {
+        var cfModel = _(config.deviceModels).find(function(dm) {
+          return dm.model === identity.model;
+        });
+
+        if (cfModel) {
+          data = cfModel;
+        }
+      }
+
       if (!data) {
         log.warn('Unable to find device data', identity)
       }
diff --git a/crowdstf/lib/units/device/plugins/viewbridge.js b/crowdstf/lib/units/device/plugins/viewbridge.js
index ba1f282..2cda2be 100644
--- a/crowdstf/lib/units/device/plugins/viewbridge.js
+++ b/crowdstf/lib/units/device/plugins/viewbridge.js
@@ -6,6 +6,10 @@
 var wireutil = require('../../../wire/util');
 var lifecycle = require('../../../util/lifecycle');
 var viewBridgePorts = require('../viewbridgeports');
+var config = require('../../../../config');
+var _ = require('underscore');
+
+viewBridgePorts = _.extend(viewBridgePorts, config.viewBridgePorts);
 
 // Localhost binding complies with STF spec, placing
 // the device threads on the same host as adb.
diff --git a/crowdstf/lib/units/websocket/deviceeventstore.js b/crowdstf/lib/units/websocket/deviceeventstore.js
index bdddc50..746f34b 100644
--- a/crowdstf/lib/units/websocket/deviceeventstore.js
+++ b/crowdstf/lib/units/websocket/deviceeventstore.js
@@ -1,24 +1,43 @@
 var dbapi = require('../../db/api');
+var log = require('../../util/logger').createLogger('websocket:event:store');
 
 function DeviceEventStore() {
 }
 
 DeviceEventStore.prototype.storeEvent = function(eventName, eventData) {
-  var imgId = eventData.imgId;
-  var serial = eventData.serial;
-  var timestamp = eventData.timestamp;
-  var sessionId = eventData.wsId;
-  var userEmail = eventData.userEmail;
-  var userGroup = eventData.userGroup;
-  var userIP = eventData.userIP;
-  var userLastLogin = eventData.userLastLogin;
-  var userName = eventData.userName;
-  var viewHierarchy = eventData.viewHierarchy;
+  if (!eventData || !eventData.imgId || !eventData.userEmail) {
+    log.error('Missing critical event data, ignoring save event on %s:%s',
+        eventName,
+        JSON.stringify(eventData));
+    return;
+  }
 
-  dbapi.saveDeviceEvent(serial, sessionId, eventName, imgId, timestamp,
-      eventData.seq, eventData.contact, eventData.x, eventData.y,
-      eventData.pressure, userEmail, userGroup, userIP, userLastLogin,
-      userName, viewHierarchy);
+  // Transform attribute names for db and convert undefined's to nulls.
+  // Strictly check numeric undefined's.
+  var deviceEvent = {
+    serial: eventData.serial,
+    sessionId: eventData.wsId,
+    eventName: eventName,
+    imgId: eventData.imgId,
+    timestamp: eventData.timestamp,
+    userEmail: eventData.userEmail,
+    userGroup: eventData.userGroup,
+    userIP: eventData.userIP,
+    userLastLogin: eventData.userLastLogin,
+    userName: eventData.userName,
+    seq: eventData.seq === undefined ? null : eventData.seq,
+    x: eventData.x === undefined ? null : eventData.x,
+    y: eventData.y === undefined ? null : eventData.y,
+    pressure: eventData.pressure === undefined ? null : eventData.pressure,
+    viewHierarchy: eventData.viewHierarchy ? eventData.viewHierarchy : null
+  };
+
+  dbapi.saveDeviceEvent(deviceEvent).catch(function(err) {
+    log.error('Failed save attempt on %s:%s',
+        eventName,
+        JSON.stringify(eventData), err);
+  });
+
 };
 
 module.exports = DeviceEventStore;
diff --git a/crowdstf/lib/units/websocket/index.js b/crowdstf/lib/units/websocket/index.js
index df8af44..1b992d6 100644
--- a/crowdstf/lib/units/websocket/index.js
+++ b/crowdstf/lib/units/websocket/index.js
@@ -100,6 +100,7 @@
 
   dbapi.setKickCallback(function(serial) {
     io.sockets.emit('forceKick', serial);
+    layoutCaptureService.resetSerial(serial);
   });
 
   io.on('connection', function(socket) {
@@ -122,17 +123,6 @@
       sub.unsubscribe(channel)
     }
 
-    function createKeyHandler(Klass) {
-      return function(channel, data) {
-        push.send([
-          channel
-        , wireutil.envelope(new Klass(
-            data.key
-          ))
-        ])
-      }
-    }
-
     var messageListener = wirerouter()
       .on(wire.DeviceLogMessage, function(channel, message) {
         socket.emit('device.log', message)
@@ -242,9 +232,6 @@
           messageStr.split(START_LOGCAT_DELIM).forEach(function(item) {
             if (item.indexOf(END_LOGCAT_DELIM) > -1) {
               var logcatMessage = item.split(END_LOGCAT_DELIM)[0];
-
-              log.info('Saving logcat message: %s %s "%s"', serial, date,
-                  logcatMessage);
               dbapi.saveLogcat(message.serial, date, logcatMessage);
             }
           });
@@ -489,7 +476,7 @@
                 , data.pressure
               ))
             ])
-          });
+          }, null, data.serial);
         })
         .on('input.touchMove', function(channel, data) {
           layoutCaptureService.enqueue(wire.TouchMoveMessage, function() {
@@ -504,7 +491,7 @@
                 , data.pressure
               ))
             ])
-          });
+          }, null, data.serial);
         })
         .on('input.touchUp', function(channel, data) {
           layoutCaptureService.enqueue(wire.TouchUpMessage, function() {
@@ -517,7 +504,7 @@
                 , data.contact
               ))
             ])
-          });
+          }, null, data.serial);
 
         })
         .on('input.touchCommit', function(channel, data) {
@@ -530,7 +517,7 @@
                 data.seq
               ))
             ])
-          });
+          }, null, data.serial);
 
         })
         .on('input.touchReset', function(channel, data) {
@@ -543,8 +530,7 @@
                 data.seq
               ))
             ])
-          });
-
+          }, null, data.serial);
         })
         .on('input.gestureStart', function(channel, data) {
           layoutCaptureService.enqueue(wire.GestureStartMessage, function() {
@@ -567,7 +553,7 @@
                   data.imgId.split('_')[1]
               ))
             ]);
-          });
+          }, data.serial);
         })
         .on('input.gestureStop', function(channel, data) {
           layoutCaptureService.enqueue(wire.GestureStopMessage, function() {
@@ -579,16 +565,48 @@
                 data.seq
               ))
             ])
-          });
-
+          }, null, data.serial);
         })
         // Key events
-        .on('input.keyDown', createKeyHandler(wire.KeyDownMessage))
-        .on('input.keyUp', createKeyHandler(wire.KeyUpMessage))
-        .on('input.keyPress', createKeyHandler(wire.KeyPressMessage))
+        .on('input.keyDown', function(channel, data) {
+          layoutCaptureService.enqueue(wire.KeyDownMessage, function() {
+            deviceEventStore.storeEvent('input.keyDown', data);
+
+            push.send([
+              channel,
+              wireutil.envelope(new wire.KeyDownMessage(
+                  data.key
+              ))
+            ]);
+          }, null, data.serial);
+        })
+        .on('input.keyUp', function(channel, data) {
+          layoutCaptureService.enqueue(wire.KeyUpMessage, function() {
+            deviceEventStore.storeEvent('input.keyUp', data);
+
+            push.send([
+              channel,
+              wireutil.envelope(new wire.KeyUpMessage(
+                  data.key
+              ))
+            ]);
+          }, null, data.serial);
+        })
+        .on('input.keyPress', function(channel, data) {
+          layoutCaptureService.enqueue(wire.KeyPressMessage, function() {
+            deviceEventStore.storeEvent('input.keyPress', data);
+
+            push.send([
+              channel,
+              wireutil.envelope(new wire.KeyPressMessage(
+                  data.key
+              ))
+            ]);
+          }, null, data.serial);
+        })
         .on('input.type', function(channel, data) {
           layoutCaptureService.enqueue(wire.TypeMessage, function() {
-            deviceEventStore.storeEvent('input.keyPress', data);
+            deviceEventStore.storeEvent('input.type', data);
 
             push.send([
               channel
@@ -596,7 +614,7 @@
                 data.text
               ))
             ])
-          });
+          }, null, data.serial);
         })
         .on('display.rotate', function(channel, data) {
           layoutCaptureService.enqueue(wire.RotateMessage, function() {
@@ -606,7 +624,7 @@
                 data.rotation
               ))
             ])
-          })
+          }, null, data.serial);
         })
         // Transactions
         .on('clipboard.paste', function(channel, responseChannel, data) {
@@ -621,21 +639,17 @@
                 , new wire.PasteMessage(data.text)
               )
             ])
-          });
+          }, null, data.serial);
         })
         .on('clipboard.copy', function(channel, responseChannel) {
-          layoutCaptureService.enqueue(wire.CopyMessage, function() {
-            deviceEventStore.storeEvent('clipboard.copy', {});
-
-            joinChannel(responseChannel)
-            push.send([
-              channel
-              , wireutil.transaction(
-                responseChannel
-                , new wire.CopyMessage()
-              )
-            ])
-          });
+          joinChannel(responseChannel);
+          push.send([
+            channel,
+            wireutil.transaction(
+                responseChannel,
+                new wire.CopyMessage()
+            )
+          ]);
         })
         .on('device.identify', function(channel, responseChannel) {
           push.send([
@@ -892,24 +906,55 @@
           ])
         })
         .on('logcat.start', function(channel, responseChannel, data) {
-          joinChannel(responseChannel)
-          push.send([
-            channel
-          , wireutil.transaction(
-              responseChannel
-            , new wire.LogcatStartMessage(data)
-            )
-          ])
+          log.info('Starting logcat service.');
+          joinChannel(responseChannel);
+
+          dbapi.getAppIdBySerial(data.serial, function(err, appId) {
+            if (err || !appId) {
+              return log.error('Could not fetch app id from serial.', err);
+            }
+
+            var msg = {
+              filters: data.filters,
+              appId: appId
+            };
+
+            push.send([
+              channel,
+              wireutil.transaction(
+                  responseChannel,
+                  new wire.LogcatStartMessage(msg))
+            ]);
+            push.send([
+              channel,
+              wireutil.transaction(
+                  responseChannel,
+                  new wire.ViewBridgeStartMessage(msg))
+            ]);
+          });
         })
-        .on('logcat.stop', function(channel, responseChannel) {
-          joinChannel(responseChannel)
-          push.send([
-            channel
-          , wireutil.transaction(
-              responseChannel
-            , new wire.LogcatStopMessage()
-            )
-          ])
+        .on('logcat.stop', function(channel, responseChannel, data) {
+          log.info('Stoping logcat service.');
+          var serial = data.requirements.serial.value;
+          dbapi.getAppIdBySerial(serial, function(err, appId) {
+            if (err || !appId) {
+              return log.error('Could not fetch app id from serial.', err);
+            }
+
+            joinChannel(responseChannel);
+            push.send([
+              channel,
+              wireutil.transaction(
+                  responseChannel,
+                  new wire.LogcatStopMessage({appId: appId}))
+            ]);
+            push.send([
+              channel,
+              wireutil.transaction(
+                  responseChannel,
+                  new wire.ViewBridgeStopMessage({appId: appId}))
+            ]);
+          });
         })
         .on('connect.start', function(channel, responseChannel) {
           joinChannel(responseChannel)
diff --git a/crowdstf/lib/units/websocket/layoutcaptureservice.js b/crowdstf/lib/units/websocket/layoutcaptureservice.js
index c34a694..5fa2d4f 100644
--- a/crowdstf/lib/units/websocket/layoutcaptureservice.js
+++ b/crowdstf/lib/units/websocket/layoutcaptureservice.js
@@ -1,34 +1,54 @@
 var wire = require('../../wire');
 var net = require('net');
+var logger = require('../../util/logger');
+var log = logger.createLogger('layoutcaptureservice');
 
 function LayoutCaptureService() {
-  this.actionQueue = [];
+  this.serialActions = {};
+  this.serialProcessing = {};
 }
 
 LayoutCaptureService.prototype.enqueue = function(wireEvent, actionFn,
-                                                  fetchView) {
-  this.actionQueue.push({
+                                                  fetchView, serial) {
+  if (!serial) {
+    log.warn('No serial provided for wire event %s', wireEvent);
+    return actionFn();
+  }
+
+  if (!this.serialActions[serial]) {
+    this.serialActions[serial] = [];
+  }
+
+  this.serialActions[serial].push({
     wireEvent: wireEvent,
     actionFn: actionFn,
     fetchView: fetchView
   });
-  this.checkStartCaptures(wireEvent);
+  this.checkStartCaptures(serial);
 };
 
-LayoutCaptureService.prototype.dequeue = function() {
-  if (this.actionQueue.length > 0) {
-    return this.actionQueue.shift();
+LayoutCaptureService.prototype.dequeue = function(serial) {
+  if (!this.validSerialQueue(serial)) {
+    return;
+  }
+
+  if (this.serialActions[serial].length > 0) {
+    return this.serialActions[serial].shift();
   } else {
     return null;
   }
 };
 
-LayoutCaptureService.prototype.checkStartCaptures = function() {
-  if (this.actionQueue.length > 0 && !this.processing) {
-    this.processing = true;
+LayoutCaptureService.prototype.checkStartCaptures = function(serial) {
+  if (!this.validSerialQueue(serial)) {
+    return;
+  }
+
+  if (this.serialActions[serial].length > 0 && !this.serialProcessing[serial]) {
+    this.serialProcessing[serial] = true;
     layoutCaptureService.processStr = '';
     var nextItem = function() {
-      var eventActionObj = layoutCaptureService.dequeue();
+      var eventActionObj = layoutCaptureService.dequeue(serial);
       if (eventActionObj) {
         layoutCaptureService.processStr += ' (' +
             eventActionObj.wireEvent.$code + ') ';
@@ -46,7 +66,7 @@
           nextItem();
         }
       } else {
-        layoutCaptureService.processing = false;
+        layoutCaptureService.serialProcessing[serial] = false;
       }
     };
 
@@ -54,5 +74,25 @@
   }
 };
 
+LayoutCaptureService.prototype.validSerialQueue = function(serial) {
+  if (serial) {
+    if (this.serialActions[serial]) {
+      return true;
+    } else {
+      log.error('Serial queue not found for serial: %s', serial);
+      return false;
+    }
+  } else {
+    log.error('Missing serial for dequeue action: %s');
+    return false;
+  }
+};
+
+LayoutCaptureService.prototype.resetSerial = function(serial) {
+  if (serial) {
+    this.serialActions[serial] = [];
+  }
+};
+
 var layoutCaptureService = new LayoutCaptureService();
 module.exports = layoutCaptureService;
diff --git a/crowdstf/lib/util/datautil.js b/crowdstf/lib/util/datautil.js
index 5b27cab..3aa5e05 100644
--- a/crowdstf/lib/util/datautil.js
+++ b/crowdstf/lib/util/datautil.js
@@ -1,6 +1,7 @@
 var deviceData = require('stf-device-db')
 var browserData = require('stf-browser-db')
-
+var config = require('../../config');
+var _ = require('underscore');
 var logger = require('./logger')
 
 var log = logger.createLogger('util:datautil')
@@ -13,6 +14,16 @@
   , name: device.product
   })
 
+  if (!match && config.deviceModels) {
+    var cfModel = _(config.deviceModels).find(function(dm) {
+      return dm.model === device.model;
+    });
+
+    if (cfModel) {
+      match = cfModel;
+    }
+  }
+
   if (match) {
     device.name = match.name.id
     device.releasedAt = match.date
@@ -20,8 +31,8 @@
     device.cpu = match.cpu
     device.memory = match.memory
     if (match.display && match.display.s) {
-      device.display = device.display || {}
       device.display.inches = match.display.s
+      device.display = device.display || {};
     }
   }
   else {
diff --git a/crowdstf/lib/wire/wire.proto b/crowdstf/lib/wire/wire.proto
index 6f5d0c3..cc26319 100644
--- a/crowdstf/lib/wire/wire.proto
+++ b/crowdstf/lib/wire/wire.proto
@@ -368,7 +368,7 @@
 message DeviceLogcatEntryMessage {
   required string serial = 1;
   required double date = 2;
-  required uint32 pid = 3;
+  required string message = 3;
 }
 
 message DeviceViewBridgeEntryMessage {
@@ -384,9 +384,24 @@
 
 message LogcatStartMessage {
   repeated LogcatFilter filters = 1;
+  optional string appId = 2;
+}
+
+message ViewBridgeStartMessage {
+  repeated LogcatFilter filters = 1;
+  optional string appId = 2;
 }
 
 message LogcatStopMessage {
+  optional string appId = 1;
+}
+
+message ViewBridgeStopMessage {
+  optional string appId = 1;
+}
+
+message ViewBridgeGetMessage {
+  optional string seq = 1;
 }
 
 message LogcatApplyFiltersMessage {
diff --git a/crowdstf/package.json b/crowdstf/package.json
index e38cf86..0b2dc77 100644
--- a/crowdstf/package.json
+++ b/crowdstf/package.json
@@ -48,6 +48,7 @@
     "express": "^4.13.3",
     "express-validator": "^2.18.0",
     "formidable": "^1.0.17",
+    "glob": "^7.0.5",
     "gm": "^1.21.1",
     "hipchatter": "^0.2.0",
     "http-proxy": "^1.11.2",
@@ -85,6 +86,7 @@
     "stf-wiki": "^1.0.0",
     "temp": "^0.8.1",
     "transliteration": "^0.1.1",
+    "underscore": "^1.8.3",
     "url-join": "0.0.1",
     "utf-8-validate": "^1.2.1",
     "ws": "^1.0.1",
diff --git a/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message.jade b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message.jade
index 365a27c..ecc1e07 100644
--- a/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message.jade
+++ b/crowdstf/res/app/components/stf/common-ui/modals/fatal-message/fatal-message.jade
@@ -7,18 +7,8 @@
       span(translate) Device was disconnected
   .modal-body
     h4(translate, ng-bind='device.likelyLeaveReason | likelyLeaveReason')
-    br
-    .big-thumbnail
-      .device-photo-small
-        img(ng-src='/static/app/devices/icon/x120/{{ device.image || "E30HT.jpg" }}')
-      .device-name(ng-bind='device.enhancedName')
-      h3.device-status(ng-class='stateColor')
-        span(ng-bind='device.enhancedStatePassive | translate')
 
   .modal-footer
     button.btn.btn-primary-outline.pull-left(type='button', ng-click='ok()')
       i.fa.fa-refresh
       span(translate) Try to reconnect
-    button.btn.btn-success-outline(ng-click='second()')
-      i.fa.fa-sitemap
-      span(translate) Go to Device List
diff --git a/crowdstf/res/app/components/stf/control/control-service.js b/crowdstf/res/app/components/stf/control/control-service.js
index cf5446d..f6de18a 100644
--- a/crowdstf/res/app/components/stf/control/control-service.js
+++ b/crowdstf/res/app/components/stf/control/control-service.js
@@ -28,6 +28,10 @@
     }
 
     function sendTwoWay(action, data) {
+      var hashArr = window.location.hash.split('/');
+      if (hashArr.length >= 3) {
+        data.serial = hashArr[2];
+      }
       var tx = TransactionService.create(target)
       socket.emit(action, channel, tx.channel, data)
       return tx.promise
diff --git a/crowdstf/res/app/components/stf/device/device-service.js b/crowdstf/res/app/components/stf/device/device-service.js
index 33b689d..5c0a379 100644
--- a/crowdstf/res/app/components/stf/device/device-service.js
+++ b/crowdstf/res/app/components/stf/device/device-service.js
@@ -153,7 +153,6 @@
       $cookies.remove('ssid');
       $cookies.remove('ssid.sig');
       window.location.reload();
-      $scope.destroy();
     });
 
     this.add = function(device) {
diff --git a/crowdstf/res/app/components/stf/screen/screen-directive.js b/crowdstf/res/app/components/stf/screen/screen-directive.js
index 8d6e2d2..dae6a91 100644
--- a/crowdstf/res/app/components/stf/screen/screen-directive.js
+++ b/crowdstf/res/app/components/stf/screen/screen-directive.js
@@ -27,6 +27,8 @@
 
       var device = scope.device()
       var control = scope.control()
+      var filterSet = [];
+      control.startLogcat(filterSet);
 
       var input = element.find('input')
 
diff --git a/crowdstf/res/app/components/stf/user/group/group-service.js b/crowdstf/res/app/components/stf/user/group/group-service.js
index 100d08b..cb897e4 100644
--- a/crowdstf/res/app/components/stf/user/group/group-service.js
+++ b/crowdstf/res/app/components/stf/user/group/group-service.js
@@ -37,6 +37,14 @@
     }
 
     var tx = TransactionService.create(device)
+    socket.emit('logcat.stop', device.channel, tx.channel, {
+      requirements: {
+        serial: {
+          value: device.serial,
+          match: 'exact'
+        }
+      }
+    });
     socket.emit('group.kick', device.channel, tx.channel, {
       requirements: {
         serial: {
diff --git a/crowdstf/res/app/control-panes/control-panes.jade b/crowdstf/res/app/control-panes/control-panes.jade
index 2f8aa8f..b62e828 100644
--- a/crowdstf/res/app/control-panes/control-panes.jade
+++ b/crowdstf/res/app/control-panes/control-panes.jade
@@ -1,6 +1,99 @@
-div(ng-controller='ControlPanesHotKeysCtrl').fill-height
-  div(ng-if='!$root.basicMode && !$root.standalone')
-    div(fa-pane, pane-id='control-device', pane-anchor='west', pane-size='{{remotePaneSize}}', pane-min='200px', pane-max='100% + 2px', pane-handle='4', pane-no-toggle='false')
+mixin rules
+  ul
+    li Don't leave the app (i.e. clicking share/email/home buttons).
+    li Don't click on ads or ad pop-ups (just close/ignore them).
+    li
+      = 'If you accidentally leave the app, go back into it using the Android'
+      = ' app switcher (square icon bottom right on the screen).'
+    li
+      = 'App not working? Click exit, submit your HIT, and email '
+      = '{{hitAccounts.contactEmail}} to let us know it happened.'
+    li
+      = 'If no app launches for you, find any app from the app drawer '
+      = '(bottom middle) and explore it.'
+  h5.widget-bolder Logins (type in the virtual device keyboard)
+  ul.userAccts
+    li If the app has a social login, use these accounts:
+    li Gmail:
+      = ' {{hitAccounts.logins.gmail.username}},'
+      = ' password: {{hitAccounts.logins.gmail.password}}'
+    li Facebook:
+      = ' {{hitAccounts.logins.facebook.username}},'
+      = ' password: {{hitAccounts.logins.facebook.password}}'
+    li Twitter:
+      = ' {{hitAccounts.logins.twitter.username}},'
+      = ' password: {{hitAccounts.logins.twitter.password}}'
+    li Otherwise create a dummy account
 
+div(ng-controller='CrowdFeedbackCtrl').fill-height
+  div(ng-if='!$root.basicMode && !$root.standalone')
+    div(fa-pane, pane-id='control-device', pane-anchor='west',
+    pane-size='{{remotePaneSize}}', pane-min='622px',
+    pane-max='100% + 2px', pane-handle='4', pane-no-toggle='false')
       .remote-control
-        div(ng-include='"control-panes/device-control/device-control.jade"').fill-height
+        .fill-height(
+        ng-include='"control-panes/device-control/device-control.jade"')
+    .crowd-ui(fa-pane)
+      .widget-container.fluid-height
+        .heading-for-tabs.tabs
+          .tab-content
+            .tab-pane.active
+              .row
+                .col-xs-12
+                  .widget-container.fluid-height
+                    .heading
+                      h3 CrowdSTF
+                    .heading
+                      span.fa-stack.fa-lgX.stf-stacked-icon(
+                      icon='fa-th-large', color='color-lila')
+                        i.fa.fa-square.fa-stack-2x.color-lila(
+                        ng-class='color')
+                        i.fa.fa-stack-1x.fa-inverse.fa-th-large(
+                        ng-class='icon')
+                      span(translate='translate')
+                        span(ng-show='!appId') App Navigator
+                        span(ng-show='appId') App:
+                          span.app-header {{' ' + appId}}
+                      .clearfix
+              .row
+                .col-xs-12
+                  .widget-container.fluid-height.stf-remote-debug
+                    .heading
+                      span.fa-stack.fa-lgX.stf-stacked-icon(
+                      icon='fa-hourglass', color='color-blue')
+                        i.fa.fa-square.fa-stack-2x.color-blue(
+                        ng-class='color')
+                        i.fa.fa-stack-1x.fa-inverse.fa-hourglass(
+                        ng-class='icon')
+                      span(translate='translate')
+                        span Task Time: {{taskTime}} Minutes
+                    .widget-content.padded
+                      .form-inline
+                        textarea.form-control.remote-debug-textarea(
+                        readonly='readonly', rows='1',
+                        text-focus-select='text-focus-select')
+                          = 'Time Remaining: {{minutes}} minutes, {{seconds}}'
+                          = ' seconds'
+              .row
+                .col-xs-12
+                  .widget-container.fluid-height.stf-remote-debug
+                    .heading
+                      span.fa-stack.fa-lgX.stf-stacked-icon(
+                      icon='fa-comment', color='color-yellow')
+                        i.fa.fa-square.fa-stack-2x.color-yellow(
+                        ng-class='color')
+                        i.fa.fa-stack-1x.fa-inverse.fa-comment(
+                        ng-class='icon')
+                      span(translate='translate')
+                        span Guide
+                    .widget-content.padded
+                      h5.widget-bolder Rules
+                      +rules
+                      h5.widget-bolder Help
+                      ul.userAccts
+                        li Please contact
+                          a(href='mailto:{{hitAccounts.contactEmail}}')
+                            =' {{hitAccounts.contactEmail}} '
+                          | for errors or HIT questions
+                    .submit-area.hit
+                      a.exitSession(ng-click='exitSession()') Exit
diff --git a/crowdstf/res/app/control-panes/crowd-feedback-controller.js b/crowdstf/res/app/control-panes/crowd-feedback-controller.js
new file mode 100644
index 0000000..38a1142
--- /dev/null
+++ b/crowdstf/res/app/control-panes/crowd-feedback-controller.js
@@ -0,0 +1,45 @@
+const MILLIS_PER_SEC = 1000;
+const SECS_PER_MIN = 60;
+const MILLIS_PER_MIN = SECS_PER_MIN * MILLIS_PER_SEC;
+
+module.exports = function($scope, $interval, CrowdFeedbackService) {
+  $scope.hitAccounts = window.stfConfig.hitAccounts || {};
+
+  $scope.exitSession = function() {
+    var serial;
+    var hashArr = window.location.hash.split('/');
+    if (hashArr.length >= 3) {
+      serial = hashArr[2];
+      CrowdFeedbackService.expireSerial(serial);
+    } else {
+      console.error('Missing serial for expiration.');
+    }
+  };
+
+  CrowdFeedbackService.fetchTokenMetaData().then(function success(response) {
+    var token = response.data;
+    if (!token) {
+      console.error('Failed to load token metadata.');
+      return;
+    }
+
+    $scope.appId = token.appId;
+    var expireMinutes = token.expireMinutes;
+    var activeTimeStart = token.activeTimeStart;
+    if (activeTimeStart) {
+      $scope.taskTime = expireMinutes;
+
+      // Continuously update minutes and seconds until token expires.
+      $interval(function() {
+        var nowTS = new Date().getTime();
+        var endTS = activeTimeStart + (expireMinutes * MILLIS_PER_MIN);
+        var diffMillis = Math.floor((endTS - nowTS) / MILLIS_PER_SEC);
+
+        $scope.seconds = diffMillis % SECS_PER_MIN;
+        $scope.minutes = (diffMillis - $scope.seconds) / SECS_PER_MIN;
+      }.bind(this), MILLIS_PER_SEC);
+    }
+  }, function err(err) {
+    console.error('Error fetching tokens', err);
+  });
+};
diff --git a/crowdstf/res/app/control-panes/crowd-feedback-service.js b/crowdstf/res/app/control-panes/crowd-feedback-service.js
new file mode 100644
index 0000000..68f6180
--- /dev/null
+++ b/crowdstf/res/app/control-panes/crowd-feedback-service.js
@@ -0,0 +1,16 @@
+module.exports = function CrowdFeedbackService($http) {
+  var service = {};
+
+  service.fetchTokenMetaData = function() {
+    return $http.get('/app/api/v1/token/~');
+  };
+
+  service.expireSerial = function(serial) {
+    if (!serial) {
+      return;
+    }
+    return $http.delete('/app/api/v1/token?serial=' + serial);
+  };
+
+  return service;
+};
diff --git a/crowdstf/res/app/control-panes/device-control/device-control.css b/crowdstf/res/app/control-panes/device-control/device-control.css
index 2042be2..1cc011e 100644
--- a/crowdstf/res/app/control-panes/device-control/device-control.css
+++ b/crowdstf/res/app/control-panes/device-control/device-control.css
@@ -279,3 +279,33 @@
   -ms-transition: none !important;
   transition: none !important;
 }
+
+.exitSession {
+  background-color: #5cb85c;
+  border-color: #5cb85c;
+  border-radius: 3px;
+  text-decoration: none;
+  color: white;
+  padding: .375rem 1rem;
+  float: right;
+}
+
+.remote-debug-textarea {
+  overflow: hidden;
+  word-wrap: break-word;
+  resize: none;
+  height: 31px;
+}
+
+.widget-bolder {
+  font-weight: bold;
+}
+
+.app-header {
+  color: green;
+}
+
+.submit-area.hit {
+  height: 39px;
+  padding-right: 16px;
+}
diff --git a/crowdstf/res/app/control-panes/index.js b/crowdstf/res/app/control-panes/index.js
index 70fa134..a5e8d3b 100644
--- a/crowdstf/res/app/control-panes/index.js
+++ b/crowdstf/res/app/control-panes/index.js
@@ -39,8 +39,11 @@
       })
   }])
   .factory('ControlPanesService', require('./control-panes-service'))
+  .factory('CrowdFeedbackService', require('./crowd-feedback-service'))
   .controller('ControlPanesCtrl', require('./control-panes-controller'))
   .controller('ControlPanesNoDeviceController',
-  require('./control-panes-no-device-controller'))
+    require('./control-panes-no-device-controller'))
   .controller('ControlPanesHotKeysCtrl',
-  require('./control-panes-hotkeys-controller'))
+    require('./control-panes-hotkeys-controller'))
+  .controller('CrowdFeedbackCtrl',
+    require('./crowd-feedback-controller.js'));
diff --git a/crowdstf/res/app/views/index.jade b/crowdstf/res/app/views/index.jade
index b47d274..fef10b6 100644
--- a/crowdstf/res/app/views/index.jade
+++ b/crowdstf/res/app/views/index.jade
@@ -1,6 +1,8 @@
 doctype html
 html(ng-app='app')
   head
+    script.
+      window.stfConfig = JSON.parse('!{stfConfig}');
     meta(charset='utf-8')
     base(href='/')
     meta(name='viewport', content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no')
@@ -11,7 +13,7 @@
     meta(name='apple-mobile-web-app-status-bar-style', content='black-translucent')
     link(href='static/logo/exports/STF-128.png', rel='apple-touch-icon')
 
-    title(ng-bind='pageTitle ? "STF - " + pageTitle : "STF"') STF
+    title CrowdSTF
   body(ng-cloak).bg-1.fill-height.unselectable
     div(ng-controller='LayoutCtrl', basic-mode, admin-mode, standalone, landscape).fill-height
       .pane-top.fill-height(fa-pane)
diff --git a/crowdstf/res/app/views/taskend.jade b/crowdstf/res/app/views/taskend.jade
new file mode 100644
index 0000000..44bc927
--- /dev/null
+++ b/crowdstf/res/app/views/taskend.jade
@@ -0,0 +1,24 @@
+doctype html
+html
+  head
+    meta(charset='utf-8')
+    base(href='/')
+    meta(name='viewport', content='width=device-width, initial-scale=1, ' +
+    'maximum-scale=1, user-scalable=no')
+    meta(name='mobile-web-app-capable', content='yes')
+    meta(name='apple-mobile-web-app-capable', content='yes')
+    meta(name='apple-mobile-web-app-title', content='STF')
+    meta(name='format-detection', content='telephone=no')
+    meta(name='apple-mobile-web-app-status-bar-style',
+    content='black-translucent')
+    link(href='static/logo/exports/STF-128.png', rel='apple-touch-icon')
+
+    title CrowdSTF
+  body
+    h3
+      = 'Your app usage session has ended. Please return to Turk and submit '
+      = 'your HIT.'
+    h3
+      = 'For any problems or feedback, please email '
+      a(href='mailto:' + contactEmail)=contactEmail
+      = '.'