Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,127 @@
/* globals AppView, ERRORS, SIMAccess, SettingsController, PinController,
AppletListView, AppletListController, HCIView, SEUtils, PAYMENT_IMG_SRC,
NONPAYMENT_IMG_SRC, PAYMENT_APPLET_FILTER, NONPAYMENT_APPLET_FILTER,
SIM_UPDATE, SIM_REFRESH */
window.addEventListener('DOMContentLoaded', function() {
'use strict';
var log = (msg) => console.log('[main-ctrl] ' + msg);
var appState = { initialised: false, sentToBackground: false };
var appView = new AppView('main', 'global-error');
if(!window.navigator.seManager) {
log('Secure Element API not available. Exiting.');
appView.showErrorView(ERRORS.GLOBAL.NO_API);
return;
}
// components and views instantiation
var pin = new PinController(SIMAccess, 'pin-ctrl', 'pin');
var settingsPin = new PinController(SIMAccess, 'settings-pin-ctrl',
'settings-pin-view', 'pin-template');
var settings = new SettingsController('settings-ctrl', 'settings',
settingsPin);
var paymentsView = new AppletListView('payments-view', PAYMENT_IMG_SRC);
var hciView = new HCIView('hci-notification', PAYMENT_IMG_SRC);
var payments = new AppletListController(SIMAccess,'payments-ctrl',
paymentsView, PAYMENT_APPLET_FILTER);
var nonPaymentsView = new AppletListView('nonpayments-view',
NONPAYMENT_IMG_SRC);
var nonPayments =
new AppletListController(SIMAccess, 'nonpayments-ctrl', nonPaymentsView,
NONPAYMENT_APPLET_FILTER, true);
// updating SIMAccess with settings read from localStorage
SIMAccess.crsAid = settings.crsAid;
SIMAccess.uiccAid = settings.uiccAid;
SIMAccess.pinP2 = settings.pinP2;
// pin verification
var performPinCheck = function performPinCheck() {
if(settings.pinEnabled && !pin.pinCheckInProgress) {
appView.hideMainView();
return pin.verifyPin().then((verified) => {
if(!verified) {
return Promise.reject('Pin verification failed');
}
appView.showMainView();
});
}
return Promise.resolve();
};
// app starting point
var initApp = function intitApp() {
appView.showMainView();
SIMAccess.start()
.then(performPinCheck)
.then(() => payments.init())
.then(() => nonPayments.init())
.then(() => { appState.initialised = true; })
.catch((error) => {
log('Init promise chain broken. Reason: ' + error);
appView.showErrorView(error);
SIMAccess.stop();
});
};
// global events, system-messages handlers
document.addEventListener('visibilitychange', () => {
log('Visibility change');
if(!document.hidden && appState.initialised) {
performPinCheck().catch((e) => appView.showErrorView(e));
}
appState.sentToBackground = document.hidden;
});
window.navigator.mozSetMessageHandler('nfc-hci-event-transaction', (msg) => {
var shouldClose = !appState.initialised || appState.sentToBackground;
var aid = SEUtils.byteToHexString(msg.aid);
log('Got HCI EVT_TRANSACTION from AID ' + aid);
// SIM card is not accessible for the first 1.5s after HCI
setTimeout(() => payments.handleHCIEvt(msg, settings.fastpayEnabled), 2000);
hciView.show(aid, shouldClose);
});
// coponent specific event handlers
appView.addListener({ onEvent: (id, data) => {
if(data.action === 'open-settings') {
settings.show();
appView.hideErrorView();
}
}});
settings.addListener({ onEvent: (id, data) => {
log('Settings event: ' + JSON.stringify(data));
if(data.error) {
appView.showErrorView(data.error);
return;
}
if(data.action === 'settings-updated') {
var refreshNeeded = false;
Object.keys(data.changes).forEach(key => {
refreshNeeded = refreshNeeded || SIM_REFRESH.indexOf(key) !== -1;
if(SIM_UPDATE.indexOf(key) !== -1) {
SIMAccess[key] = data.changes[key];
}
});
if(refreshNeeded) {
initApp();
}
}
}});
log('Init access, getting applets from CRS');
initApp();
});

View file

@ -0,0 +1,53 @@
'use strict';
/* globals ErrorView, addMixin, ObserverSubjectMixin */
/* exported AppView */
(function(exports) {
var AppView = function(mainViewId, errorViewId) {
this._mainEl = document.querySelector('#' + mainViewId);
this._errorView = new ErrorView(errorViewId);
this._errorView.addListener(this);
var settingsBtn = this._mainEl.querySelector('#settings-edit');
settingsBtn.addEventListener('click', () => this._settingsClicked());
addMixin(this, ObserverSubjectMixin);
};
AppView.prototype = {
_mainEl: null,
_errorView: null,
showMainView: function showMainView() {
this._mainEl.classList.remove('hide');
this._errorView.hide();
},
hideMainView: function hideMainView() {
this._mainEl.classList.add('hide');
},
showErrorView: function showErrorView(error) {
this._errorView.handleError(error);
this.hideMainView();
},
hideErrorView: function hideErrorView() {
this._errorView.hide();
},
onEvent: function onEvent(id, data) {
if(data.action === 'open-settings') {
this._settingsClicked();
}
},
_settingsClicked: function _settingsClicked() {
this._notify({ action: 'open-settings' });
},
},
exports.AppView = AppView;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,32 @@
'use strict';
/* globals addMixin, DetailsViewMixin, DebugMixin */
/* exported AppletDetailsView */
(function(exports) {
var AppletDetailsView = function(id, applet) {
addMixin(this, DetailsViewMixin);
addMixin(this, DebugMixin);
this.initDetailsView(id, applet);
};
AppletDetailsView.prototype = {
_template:
'<ul class="details">' +
'<li id="aid"><p class="label">AID</p><p class="value"></p></li>' +
'<li class="details-more">' +
'<p class="details-note">' +
'More details read from the applet can be presented here' +
'</p>' +
'</li>' +
'</ul>',
_render: function(applet) {
this._el.querySelector('#aid .value')
.textContent = applet.aid;
},
};
exports.AppletDetailsView = AppletDetailsView;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,136 @@
'use strict';
/* globals AppletListModel, AppletListView, Timer, SEUtils,
addMixin, DebugMixin */
/* exported AppletListController */
(function(exports) {
var AppletListController = function(simDataSource, id, view, filter, multipleAppletsActive){
this._simDataSource = simDataSource;
this._id = id;
this._filter = filter;
this._multipleAppletsActive = multipleAppletsActive;
this._view = view;
this._view.addListener(this);
addMixin(this, DebugMixin);
};
AppletListController.prototype = {
_id: null,
_viewId: null,
_multipleAppletsActive: false,
_simDataSource: null,
_model: null,
_view: null,
_filter: null,
init: function alc_init() {
return this.refreshModelView();
},
refreshModelView: function alc_refreshModelView() {
return this._refreshModel()
.then((simAccessTime) => {
var viewUpdateTime = this._refreshView();
this.debug('Applets read in ' + simAccessTime + ' ms. ' +
'View refreshed in ' + viewUpdateTime + ' ms.');
return simAccessTime;
});
},
// TODO unify AID arguments
handleHCIEvt: function alc_handleHCIEvt(hci, fastPayOn) {
var aid = SEUtils.byteToHexString(hci.aid);
var applet = this._model.getAppletByHexAID(aid);
if(!applet) {
this.debug('Got HCI from uknown aid '+ aid);
return;
}
this.debug('HCI EVT_TRANSACTION from ' + aid);
if(!fastPayOn) {
this._toggleAppletCLF(aid, false)
.catch((e) => console.error(e));
}
},
onEvent: function alc_onEvent(source, data) {
if(data.action === 'details-hide') {
this._view.hideDetails();
return;
}
if(data.action === 'details-show') {
this._view.showDetails(this._model.getAppletByID(data.id));
return;
}
// TODO add explicit event
var applet = this._model.getAppletByID(data.id);
var select = data.action === 'card-selection';
this._toggleAppletCLF(applet.aid, select);
},
showView: function alc_showView() {
this._view.visible = true;
},
hideView: function alc_hideView() {
this._view.visible = false;
},
_toggleAppletCLF(aid, toggle) {
this._view.disableCardSelection();
return this._simDataSource.toggleCLF(toggle, aid)
// TODO add comment about CREL and auto off
//.then(() => this._refreshModel())
.catch((e) => this.debug('Toggle fast pay failed', e))
.then((success) => {
if(success) {
this._model.applets.forEach((a) => {
if(a.aid === aid) {
a.contactlessState = toggle ? 1 : 0;
} else if (toggle && !this._multipleAppletsActive) {
a.contactlessState = 0;
}
});
}
this._view.updateSelection(this._model.applets);
this._view.enableCardSelection();
});
},
_refreshModel: function alc_refreshMode() {
var timer = new Timer();
return this._simDataSource.getAppletsData()
.then((applets) => {
var simAccTime = timer.getElapsedTime();
if(!this._model) {
this._model = new AppletListModel();
}
this._model.updateList(applets, this._filter);
return simAccTime;
});
},
_refreshView: function alc_refreshView() {
var timer = new Timer();
this._view.refreshView(this._model.applets);
return timer.getElapsedTime();
}
};
exports.AppletListController = AppletListController;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,60 @@
'use strict';
/* globals addMixin, ObserverSubjectMixin, CardSwipeMixin, getSamplePaymentImg,
ListViewMixin, AppletDetailsView, DebugMixin */
/* exported AppletListView */
(function(exports) {
var AppletListView = function(id, imgSrc, data) {
this._id = id;
addMixin(this, ObserverSubjectMixin);
addMixin(this, ListViewMixin);
addMixin(this, CardSwipeMixin);
addMixin(this, DebugMixin);
this.initListView(id, imgSrc, data);
this._detailsView = new AppletDetailsView(id + '-details');
};
AppletListView.prototype = {
_detailsVisible: false,
_detailsView: null,
refreshView: function(data) {
this.updateList(data);
if(!Array.isArray(data) || !data.length) {
return;
}
var tapCallback = (id) => {
var action = 'details-' + ((this._detailsVisible) ? 'hide' : 'show');
this._notify({ id: id, action: action});
};
var swipeCallback = (activeId) => {
if(this._detailsVisible) {
this.hideDetails();
}
};
this.initCardSwipe('li', tapCallback, swipeCallback);
this.updateSelection(data);
},
showDetails: function alv_showDetails(applet) {
this._el.querySelector('.slider').scrollTop = 0;
this._detailsView.refreshView(applet);
this._el.classList.add('details-visible');
this._detailsVisible = true;
},
hideDetails: function alv_hideDetails() {
this._el.classList.remove('details-visible');
this._detailsVisible = false;
},
};
exports.AppletListView = AppletListView;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,105 @@
'use strict';
/* globals SEUtils, TAG */
/* exported AppletModel, AppletListModel */
(function(exports) {
// TODO decide how to store data in the app
var AppletModel = function(id, aid, state){
this.id = id;
this.aid = SEUtils.byteToHexString(aid);
this.lifecycleState = state[0];
this.contactlessState = state[1];
};
AppletModel.prototype = {
// id in view
id: null,
aid: null,
lifecycleState: null,
contactlessState: null,
set state(value) {
this.lifecycleState = value[0];
this.contactlessState = value[1];
},
get selected() {
return this.contactlessState === 1;
},
get imgId() {
return this.aid;
}
};
var AppletListModel = function(appletsData, filter) {
if(appletsData) {
this.updateList(appletsData, filter);
}
};
AppletListModel.prototype = {
applets: [],
updateList: function alm_updateList(appletsData, filter) {
var parsedApplets = this._parseAppletList(appletsData);
parsedApplets = (!filter) ? parsedApplets : parsedApplets.filter(filter);
// now we create AppletModel objects and store them in the list
var refreshed = [];
parsedApplets.forEach((appl, idx) => {
var aidHex = SEUtils.byteToHexString(appl.aid);
var nAppl = this.getAppletByHexAID(aidHex);
if(nAppl) {
nAppl.state = appl.state;
} else {
nAppl = new AppletModel('appl'+idx, appl.aid, appl.state);
}
refreshed.push(nAppl);
});
this.applets = refreshed;
},
getAppletByID: function alm_getAppletByID(id) {
return this.applets.find((applet) => applet.id === id);
},
getAppletByHexAID: function alm_getAppletByHexAID(aid) {
return this.applets.find((applet) => applet.aid === aid);
},
_parseAppletList: function alm_parseAppletList(data) {
var list = [];
for(var i = 0, len = data.length; i < len;) {
if(data[i] === TAG.CRS.APP_TEMPLATE) {
var end = i+2 + data[i+1];
var appletData = data.subarray(i+2, end);
var applet = this._parseApplet(appletData);
if(applet) {
list.push(applet);
}
i = end;
}
}
return list;
},
_parseApplet: function alm_parseApplet(data) {
if (data[0] === TAG.CRS.AID) {
var applet = {
aid: data.subarray(2, 2 + data[1]),
state: data.subarray(data[1] + 5)
};
return applet;
}
},
};
exports.AppletModel = AppletModel;
exports.AppletListModel = AppletListModel;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,43 @@
'use strict';
/* exported HCIView */
(function(exports) {
const CLOSE_TIMEOUT = 1000;
var HCIView = function(id, imgSrc) {
this._el = document.querySelector('#'+id);
this._imgSrc = imgSrc;
};
HCIView.prototype = {
_el: null,
_imgSrc: null,
_clickHandler: null,
show: function show(aid, closeApp) {
window.navigator.vibrate([50, 50, 50]);
this._el.querySelector('#hci-image').src = this._imgSrc(aid);
this._el.querySelector('#hci-number').textContent = aid;
this._clickHandler = () => this._dismiss(closeApp);
this._el.addEventListener('click', this._clickHandler);
this._el.classList.add('show');
},
_dismiss: function _dismiss(closeApp) {
if(closeApp) {
setTimeout(() => window.close(), CLOSE_TIMEOUT);
return;
}
this._el.classList.remove('show');
this._el.removeEventListener('click', this._clickHandler);
},
};
exports.HCIView = HCIView;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,99 @@
'use strict';
/* exported CardSwipeMixin */
/* globals Swipeable */
(function(exports) {
/**
* Needs to be used with ObserverSubjectMixin
*/
var CardSwipeMixin = {
_swipeHandler: null,
initCardSwipe: function initCardSwipe(selector, tapCb, swipeCb, options) {
options = options || {
'element-width': 260,
'auto-scroll-trigger': 0.25,
'min-speed': 0.75
};
if(this._swipeHandler) {
this._swipeHandler.disable();
this._swipeHandler.onswipe = null;
this._swipeHandler.ontap = null;
this._swipeHandler.onvertswipe = null;
this._swipeHandler = null;
}
this._swipeHandler = new Swipeable(this._el, selector, options);
if(tapCb) {
this._swipeHandler.ontap = tapCb;
}
if(swipeCb) {
this._swipeHandler.onswipe = swipeCb;
}
this._swipeHandler.onvertswipe = (direction, id) => {
var itemEl = this._el.querySelector('#' + id);
var action = '';
if(direction === 'up' && !itemEl.classList.contains('selected')) {
action = 'card-selection';
this._changeItemSelection(itemEl, true);
}
if(direction === 'down' && itemEl.classList.contains('selected')) {
action = 'card-deselection';
this._changeItemSelection(itemEl, false);
}
if(action) {
this._notify({ id: id, action: action});
}
};
this._swipeHandler.enable();
},
updateSelection: function updateSelection(items) {
items.forEach((item, idx) => {
var itemEl = this._el.querySelector('#'+item.id);
if(item.selected && !itemEl.classList.contains('selected')) {
this._changeItemSelection(itemEl, true);
}
if(!item.selected && itemEl.classList.contains('selected')) {
this._changeItemSelection(itemEl, false);
}
});
},
enableCardSelection: function enablCardSelection() {
this._swipeHandler.unpauseSwipe();
},
disableCardSelection: function disableCardSelection() {
this._swipeHandler.pauseSwipe();
},
_changeItemSelection: function changeItemSelection(itemEl, select) {
var elIdx = this._swipeHandler._elts.indexOf(itemEl);
if(elIdx === -1) {
return;
}
if(select) {
itemEl.classList.remove('deselected');
itemEl.classList.add('selected');
this._swipeHandler.progressSelect(elIdx, true);
} else {
itemEl.classList.remove('selected');
itemEl.classList.add('deselected');
this._swipeHandler.progressSelect(elIdx, false);
}
},
};
exports.CardSwipeMixin = CardSwipeMixin;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,46 @@
'use strict';
/* exported DetailsViewMixin */
(function(exports) {
var DetailsViewMixin = {
_id: null,
_el: null,
_visible: false,
_template: '',
initDetailsView: function(id, data) {
this._id = id;
this._el = document.querySelector('#' + this._id);
this._el.innerHTML = this._template;
if(data) {
this._render(data);
}
},
isVisible: function() {
return this._visible;
},
show: function() {
this._visible = true;
this._el.classList.add(this._id+'-visible');
},
hide: function() {
this._visible = false;
this._el.classList.remove(this._id+'-visible');
},
_render: function(data) {},
refreshView: function(data) {
if(data) {
this._render(data);
}
}
};
exports.DetailsViewMixin = DetailsViewMixin;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,87 @@
'use strict';
/* globals CARD_UNKNOWN */
/* exported ListViewMixin */
(function(exports) {
var ListViewMixin = {
_id: null,
_el: null,
_visible: false,
_imgMap: null,
_itemTemplate:
'<div class="listview-element-pocket">' +
'<div class="listview-element-sew"></div>' +
'</div>' +
'<img class="listview-element-img" src="img/cards/unknown.png">',
initListView: function(id, imgMap, data) {
this._id = id;
this._el = document.querySelector('#' + this._id);
this._imgMap = imgMap;
if(data && data.length) {
this._render(data);
}
},
updateList(listItems) {
if(!Array.isArray(listItems) || !listItems.length) {
this.debug('list without elements', listItems);
this._renderEmpty();
return;
}
this._render(listItems);
},
isVisible: function() {
return this._visible;
},
show: function() {
this._visible = true;
this._el.classList.remove('listview-hide');
},
hide: function() {
this._visible = false;
this._el.classList.add('listview-hide');
},
_render: function(data){
var emptyLi = document.createElement('li');
emptyLi.innerHTML = this._itemTemplate;
var ulFragment = document.createDocumentFragment();
data.forEach((item) => {
var li = emptyLi.cloneNode(true);
li.id = item.id;
var src = this._imgMap(item.imgId);
// TODO remove CARD_UNKNOW dependency here
li.querySelector('img').src = src ? src : CARD_UNKNOWN;
ulFragment.appendChild(li);
});
var ul = this._el.querySelector('ul');
ul.innerHTML = '';
ul.appendChild(ulFragment);
},
_renderEmpty: function() {
var emptyLi = document.createElement('li');
emptyLi.innerHTML = this._itemTemplate;
var ul = this._el.querySelector('ul');
ul.innerHTML = '';
ul.appendChild(emptyLi);
},
};
exports.ListViewMixin = ListViewMixin;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,209 @@
'use strict';
/* globals GestureDetector */
/* exported Swipeable */
(function(exports) {
var Swipeable = function(container, eltSelector, options) {
this._container = container.querySelector('.swipeable-container');
this._eltSelector = eltSelector;
this.updateElements();
this._createProgress();
this._gestureDetector = new GestureDetector(this._container,
{ holdEvents: false });
this._panHandler = e => this._handlePan(e);
this._swipeHandler = e => this._handleSwipe(e);
this._container.addEventListener('tap', (e) => {
if(this._tapCallback) {
this._tapCallback(this._elts[this._activeEltIdx].id);
}
});
options = options || {};
this._elWidth = options['element-width'] || this._elts[0].offsetWidth;
this._triggerPoint = options['auto-scroll-trigger'] || this._triggerPoint;
this._minSpeed = options['min-speed'] || this._minSpeed;
};
Swipeable.prototype = {
_gestureDetector: null,
_container: null,
_eltSelector: null,
_elts: [],
_progressElts: null,
_elWidth: 0,
_triggerPoint: 0.25,
_minSpeed: 1.0,
_inTransition: false,
_offset: 0,
_activeEltIdx: 0,
_tapCallback: null,
_swipeCallback: null,
_vertSwipeCallback: null,
set ontap(callback) {
this._tapCallback = callback;
},
set onswipe(callback) {
this._swipeCallback = callback;
},
set onvertswipe(callback) {
this._vertSwipeCallback = callback;
},
enable: function() {
this._gestureDetector.startDetecting();
this.unpauseSwipe();
},
disable: function() {
this._gestureDetector.stopDetecting();
},
pauseSwipe: function() {
this._container.removeEventListener('pan', this._panHandler);
this._container.removeEventListener('swipe', this._swipeHandler);
},
unpauseSwipe: function() {
this._container.addEventListener('pan', this._panHandler);
this._container.addEventListener('swipe', this._swipeHandler);
},
updateElements: function() {
var nodeList = this._container.querySelectorAll('.swipeable > ' + this._eltSelector);
this._elts = Array.prototype.slice.call(nodeList);
},
_createProgress: function() {
if(!this._elts || !this._elts.length) {
return;
}
var progressDiv = this._container.querySelector('.swipeable-progress');
if(!progressDiv) {
return;
}
var progressFragment = document.createDocumentFragment();
this._progressElts = this._elts.map(() => {
var div = document.createElement('div');
progressFragment.appendChild(div);
return div;
});
this._progressElts[0].classList.add('swipeable-progress-active');
progressDiv.innerHTML = '';
progressDiv.appendChild(progressFragment);
},
_updateProgress: function() {
this._progressElts.forEach((elt, idx) => {
if(idx === this._activeEltIdx) {
elt.classList.add('swipeable-progress-active');
} else if(elt.classList.contains('swipeable-progress-active')) {
elt.classList.remove('swipeable-progress-active');
}
});
},
progressSelect: function progressSelect(idx, select) {
var pElt = this._progressElts[idx];
if(!pElt) {
return;
}
if(select) {
pElt.classList.add('swipeable-progress-selected');
} else if(pElt.classList.contains('swipeable-progress-selected')) {
pElt.classList.remove('swipeable-progress-selected');
}
},
_handlePan: function(event) {
if(this._inTransition) {
return;
}
if (Math.abs(event.detail.relative.dx) > Math.abs(event.detail.relative.dy)) {
this._offset += event.detail.relative.dx;
this._animate();
}
},
_handleSwipe: function(event) {
if(this._vertSwipeCallback &&
['up', 'down'].indexOf(event.detail.direction) !== -1) {
var id = this._elts[this._activeEltIdx].id;
this._vertSwipeCallback(event.detail.direction, id);
}
var elPos = Math.abs(this._offset)%this._elWidth;
var triggerPassed = elPos > this._elWidth * this._triggerPoint;
var velocity = event.detail.vx;
var fastEnough = Math.abs(velocity) > this._minSpeed;
var direction = (event.detail.direction === 'left') ? 1 : -1;
var newIdx = this._computeNewIndex(direction);
// Make sure that that the speed and pan amount are in the same direction
//var samedirection = velocity === 0 || this._offset / velocity >= 0;
if((triggerPassed || fastEnough) && newIdx !== this._activeEltIdx) {
var speed = Math.max(Math.abs(velocity), this._minSpeed);
var time = (this._elWidth - Math.abs(this._offset%this._elWidth)) / speed;
this._activeEltIdx = newIdx;
this._performScroll(time);
this._updateProgress();
if (this._swipeCallback) {
this._swipeCallback(this._activeEltIdx);
}
} else if(this._offset !== 0){
var time = Math.abs(this._offset%this._elWidth) / this._minSpeed;
this._performScroll(time);
if (this._swipeCallback) {
this._swipeCallback(this._activeEltIdx);
}
}
},
_animate: function(time) {
var handler = (e) => {
e.target.style.transition = null;
e.target.removeEventListener('transitionend', handler);
};
this._elts.forEach((el) => {
if(time) {
el.style.transition = 'transform ' + time + 'ms ease';
el.addEventListener('transitionend', handler);
}
el.style.transform = 'translate3d('+this._offset+'px,0,0)';
});
},
_performScroll: function(time) {
this._inTransition = true;
this._offset = -1 * this._activeEltIdx * this._elWidth;
this._animate(time);
setTimeout(() => { this._inTransition = false; }, time);
},
_computeNewIndex: function(direction) {
var index = this._activeEltIdx + direction;
var offBounds = index < 0 || index >= this._elts.length;
return offBounds ? this._activeEltIdx : index;
}
};
exports.Swipeable = Swipeable;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,82 @@
'use strict';
/* exported Timer, addMixin, ObserverSubjectMixin, DebugMixin */
(function(exports) {
function addMixin(destination, source) {
for (var k in source) {
if (source.hasOwnProperty(k) && !(k in destination)) {
destination[k] = source[k];
}
}
return destination;
}
var DebugMixin = {
DEBUG: true,
debug: function(msg, obj) {
if(!this.DEBUG) {
return;
}
var str = (this._id) ? ('[' + this._id + '] ' + msg) : msg;
if(obj) {
str += '|' + JSON.stringify(obj);
}
console.log(str);
}
};
var ObserverSubjectMixin = {
_notify: function mlv_notify(data) {
if(this._observers && Array.isArray(this._observers)) {
this._observers.forEach((obs) => obs.onEvent(this._id, data));
}
},
addListener: function mlv_addListener(observer) {
if('onEvent' in observer) {
if(!this._observers) {
this._observers = [];
}
this._observers.push(observer);
}
},
removeListener: function mlv_removeListener(observer) {
if(!this._observers || !Array.isArray(this._observers)) {
return;
}
var idx = this._observers.indexOf(observer);
if(idx !== -1) {
this._observers.splice(idx, 1);
}
},
};
var Timer = function() {
this._startTime = new Date();
};
Timer.prototype = {
_startTime: null,
getElapsedTime: function getElapsedTime() {
var endTime = new Date();
return endTime.getTime() - this._startTime.getTime();
},
reset: function reset() {
this._startTime = new Date();
}
};
exports.addMixin = addMixin;
exports.DebugMixin = DebugMixin;
exports.ObserverSubjectMixin = ObserverSubjectMixin;
exports.Timer = Timer;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,24 @@
'use strict';
/* exported ERRORS */
(function(exports) {
const ERRORS = {
SIM: {
NOT_IDLE: 'Wrong sim access state.',
ACCESS_FAILED: 'Failed to enable SIM access.',
NO_READER: 'No reader with SE present.',
APPLETS_FAILURE: 'Failed to get applets data.',
PIN_FAILURE: 'Failed to issue PIN command.',
},
GLOBAL: {
NO_API: 'No Secure Element API present.',
NO_SIM: 'No SIM present.',
SIM_FAILURE: 'SIM failure.',
UNKNOWN: 'Unknown error.'
}
};
exports.ERRORS = ERRORS;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,73 @@
'use strict';
/* globals ERRORS, addMixin, ObserverSubjectMixin */
(function(exports) {
const ERROR_VIEW_ID = {};
ERROR_VIEW_ID[ERRORS.GLOBAL.NO_API] = 'no-api';
ERROR_VIEW_ID[ERRORS.GLOBAL.NO_SIM] = 'no-sim';
ERROR_VIEW_ID[ERRORS.GLOBAL.SIM_FAILURE] = 'sim-error';
ERROR_VIEW_ID[ERRORS.GLOBAL.Unknown] = 'unknown-error';
var ErrorView = function(id) {
this._el = document.querySelector('#' + id);
addMixin(this, ObserverSubjectMixin);
var settingsBtn = this._el.querySelector('.error-settings-btn');
settingsBtn.addEventListener('click',
() => this._notify({ action: 'open-settings'}));
};
ErrorView.prototype = {
_el: null,
show: function() {
this._el.classList.remove('hide');
},
hide: function() {
this._el.classList.add('hide');
},
handleError: function(error) {
console.error(error);
var globalError = this._mapErrorToGlobal(error);
this._showGlobalError(globalError);
this.show();
},
_mapErrorToGlobal: function(error) {
if(Object.keys(ERRORS.GLOBAL).find(k => ERRORS.GLOBAL[k] === error)) {
return error;
}
// mapping no reader to no SIM
if(ERRORS.SIM.NO_READER === error) {
return ERRORS.GLOBAL.NO_SIM;
}
if(Object.keys(ERRORS.SIM).find(k => ERRORS.SIM[k] === error)) {
return ERRORS.GLOBAL.SIM_FAILURE;
}
return ERRORS.GLOBAL.UNKNOWN;
},
_showGlobalError: function(globalError) {
var errorViewId = ERROR_VIEW_ID[globalError];
var nodesList = this._el.querySelectorAll('article');
[].slice.call(nodesList).forEach(e => {
if(e.id === errorViewId) {
e.classList.remove('hide');
} else {
e.classList.add('hide');
}
});
},
};
exports.ErrorView = ErrorView;
}(window));

View file

@ -0,0 +1,66 @@
'use strict';
/* globals SEUtils */
/* exported PAYMENT_IMG_SRC, NONPAYMENT_IMG_SRC, PAYMENT_APPLET_FILTER,
NONPAYMENT_APPLET_FILTER, CARD_UNKNOWN */
(function(exports) {
const CARD_UNKNOWN = 'img/cards/unknown.png';
const SAMPLE_NONPAYMENT_IMG = [
'img/cards/other-1.png',
'img/cards/other-2.png',
'img/cards/other-3.png'
];
const SAMPLE_PAYMENT_IMG = [
'img/cards/payment-1.png',
'img/cards/payment-2.png',
'img/cards/payment-3.png'
];
var _getSampleImg = function(imageArray) {
var counter = 0;
var imgMap = {};
return function(id) {
var imgSrc = imgMap[id];
if(imgSrc) {
return imgSrc;
}
counter = (imageArray.length - 1) === counter ? 0 : ++counter;
if(id) {
imgMap[id] = imageArray[counter];
}
return imageArray[counter];
};
};
var PAYMENT_IMG_SRC = _getSampleImg(SAMPLE_PAYMENT_IMG);
var NONPAYMENT_IMG_SRC = _getSampleImg(SAMPLE_NONPAYMENT_IMG);
var PAYMENT_APPLET_FILTER = function(appletData) {
var hexAid = SEUtils.byteToHexString(appletData.aid);
var hexState = SEUtils.byteToHexString(appletData.state);
// hex states for payment applets
// 1F00 - personalised, not active on Contactless interface (CLF)
// 1F01 - personalised, active on CLF
// 0708 - not personalised, not possible to use in payment
return hexAid.startsWith('A000000004') &&
(hexState === '1F00' || hexState === '1F01');
};
var NONPAYMENT_APPLET_FILTER = function(appletData) {
var hexAid = SEUtils.byteToHexString(appletData.aid);
return hexAid.startsWith('D');
};
exports.CARD_UNKNOWN = CARD_UNKNOWN;
exports.PAYMENT_IMG_SRC = PAYMENT_IMG_SRC;
exports.NONPAYMENT_IMG_SRC = NONPAYMENT_IMG_SRC;
exports.PAYMENT_APPLET_FILTER = PAYMENT_APPLET_FILTER;
exports.NONPAYMENT_APPLET_FILTER = NONPAYMENT_APPLET_FILTER;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,316 @@
'use strict';
/* globals addMixin, DebugMixin, PinView, DEFAULTS, PIN_DEFAULTS */
/* exported PinController */
(function(exports) {
const PIN_STATE = {
UNKNOWN: 'unknown',
ENABLED: 'enabled',
DISABLED: 'disabled',
BLOCKED: 'blocked',
};
var _defaultPin = DEFAULTS.PIN_VALUE;
var PinController = function(simDataSource, id, viewId, templateId) {
this._simDataSource = simDataSource;
this._id = id;
this._view = new PinView(viewId, PIN_DEFAULTS.PIN_LEN, templateId);
addMixin(this, DebugMixin);
};
PinController.prototype = {
_id: null,
_simDataSource: null,
_view: null,
// TODO consider removing
_simPinState: PIN_STATE.UNKNOWN,
_pinCheckInProgress: false,
_lastInputedPin: null,
get pinCheckInProgress() {
return this._pinCheckInProgress;
},
set defaultPin(value) {
_defaultPin = value;
},
get view() {
return this._view._el;
},
hideView: function() {
this._view.hide();
},
verifyPin: function() {
return this._requestPinVerification().then((result) => {
if(result) {
this.hideView();
}
return result;
});
},
enablePin: function() {
return this._requestPinVerification()
.then((verified) => {
if(verified) {
this.debug('Pin verified');
this._simPinState = PIN_STATE.ENABLED;
this._lastInputedPin = null;
return true;
} else {
this.debug('Pin verification failed, pin probably disabled');
var pin = this._lastInputedPin;
this._lastInputedPin = null;
return this._enableSimPin(pin);
}
});
},
disablePin: function() {
return this._requestPinVerification()
.then((verified) => {
if(verified) {
this.debug('Pin verified');
var pin = this._lastInputedPin;
this._lastInputedPin = null;
return this._disableSimPin(pin);
} else {
this.debug('Pin verification failed, pin probably disabled');
this._lastInputedPin = null;
return true;
}
});
},
changePin: function() {
var pin;
return this._requestPinVerification(this._view.REQUEST_BEFORE_CHANGE)
.then((verified) => {
if(!verified) {
this.debug('Pin not verfied, not changing');
this._lastInputedPin = null;
return false;
}
pin = this._lastInputedPin;
this._lastInputedPin = null;
return this._getNewPin();
})
.then((newPin) => {
this.debug('New pin value ' + newPin);
var pinBytes = this._formatPin(pin);
var newPinBytes = this._formatPin(newPin);
return this._simDataSource.changePIN(pinBytes, newPinBytes);
})
.then((result) => this._handlePinChangeResult(result));
},
cancelPinAction: function() {
this._view.cancelPinRequest();
},
_handlePinChangeResult: function(result) {
this.debug('Pin change result ' + result);
if(result === '9000') {
return true;
} else {
this.debug('Unexpected error');
return false;
}
},
_getNewPin: function(prevAttempFailed) {
var newPin;
return this._getUserPin(PIN_DEFAULTS.MAX_PUK_ATTEMPTS, prevAttempFailed,
this._view.REQUEST_NEW_PIN)
.then((pin1) => {
newPin = pin1;
return this._getUserPin(PIN_DEFAULTS.MAX_PUK_ATTEMPTS, false,
this._view.REQUEST_CONFIRM);
})
.then((pin2) => {
if(newPin === pin2) {
return newPin;
}
this.debug('Pins do not match');
return this._getNewPin(true);
});
},
_requestPinVerification: function(reqType) {
this.debug('_requestPinVerification, SIM PIN : ' + this._simPinState);
// TODO verify if we need this
//if(this._pinCheckInProgress) {
// return Promise.resolve(false);
//}
this._pinCheckInProgress = true;
this._view.show();
return this._performVerification(PIN_DEFAULTS.MAX_PIN_ATTEMPTS, false, reqType)
.then((verified) => {
if(verified) {
//this._view.hide();
// TODO think this through
this._pinCheckInProgress = false;
}
return verified;
});
},
_getUserPin: function(attemptsLeft, lastVerFailed, reqType, pinLen) {
return new Promise((resolve, reject) => {
var listener = {
onEvent: (id, data) => {
this._view.removeListener(listener);
if (data.pin) {
this.debug('got PIN: ' + data.pin);
resolve(data.pin);
} else {
reject('pin-cancelled');
}
}
};
this._view.addListener(listener);
if(!this._view.isVisible) {
this._view.show();
}
this.debug('waiting for PIN input from user');
this._view.requestPin(attemptsLeft, lastVerFailed, reqType, pinLen);
});
},
_performVerification: function(attemptsLeft, lastVerFailed, reqType) {
return this._getUserPin(attemptsLeft, lastVerFailed, reqType)
.then((pin) => {
this._lastInputedPin = pin;
var pinBytes = this._formatPin(pin);
this.debug('verifying PIN ' + pinBytes);
return this._simDataSource.verifyPIN(pinBytes);
})
.then((result) => this._handleVerficationResult(result));
//TODO should we catch here?
},
_handleVerficationResult: function(result) {
this.debug('Verification finished, status word:' + result);
if(result === '9000') {
return true;
} else if(result === '63C0' || result === '6983') {
this.debug('Pin blocked');
return this._handlePinBlocked();
} else if(result.startsWith('63C')) {
var attemptsLeft = Number.parseInt(result[3]);
this.debug('Verification failed, tries left: ' + attemptsLeft);
return this._performVerification(attemptsLeft, true);
} else {
this.debug('Unknown status word, PIN probably not enabled');
return false;
}
},
_handlePinBlocked: function() {
this._simPinState = PIN_STATE.BLOCKED;
this.debug('PUK unblock flow start.');
var unblockRequested = () => this._performPinUnblock(PIN_DEFAULTS.MAX_PUK_ATTEMPTS);
return this._view.blockPin().then(unblockRequested);
},
_performPinUnblock: function(attemptsLeft, lastUnblockFailed) {
var puk;
return this._getUserPin(attemptsLeft, lastUnblockFailed,
this._view.REQUEST_PUK, PIN_DEFAULTS.MAX_SIM_PIN_LEN)
.then((inputedPuk) => {
this.debug('Inputed PUK ' + inputedPuk);
puk = inputedPuk;
return this._getNewPin(false);
})
.then((inputedPin) => {
this.debug('Inputed PIN ' + inputedPin);
var bytePuk = this._formatPin(puk);
var bytePin = this._formatPin(inputedPin);
// if we're part of disable/enable flow we need to save pin here
this._lastInputedPin = inputedPin;
return this._simDataSource.unblockPIN(bytePuk, bytePin);
})
.then((result) => this._handlePinUnblockResult(result));
},
_handlePinUnblockResult: function(result) {
this.debug('PIN unblock result ' + result);
if(result === '9000') {
return true;
} else if(result === '63C0' || !result.startsWith('63C')) {
this.debug('Complate block');
return this._view.blockPuk();
} else {
var attemptsLeft = Number.parseInt(result[3]);
this.debug('PIN unblock failed, tries left: ' + attemptsLeft);
return this._performPinUnblock(attemptsLeft, true);
}
},
_enableSimPin: function(pin) {
var pinBytes = this._formatPin(pin);
return this._simDataSource.enablePIN(pinBytes)
.then((result) => this._handlePinEnabledResult(result));
},
_handlePinEnabledResult: function(result) {
this.debug('PIN enable result ' + result);
if(result === '9000') {
this._simPinState = PIN_STATE.ENABLED;
//this._view.hide();
return true;
} else if(result === '63C0' || result === '6983') {
this.debug('Pin blocked');
return this._handlePinBlocked();
} else {
var tries = result.startsWith('63C') ? Number.parseInt(result[3]) : 0;
this._view.showEnableFailed(tries, _defaultPin);
return false;
}
},
_disableSimPin: function(pin) {
var pinBytes = this._formatPin(pin);
return this._simDataSource.disablePIN(pinBytes)
.then((result) => this._handlePinDisableResult(result));
},
_handlePinDisableResult: function(result) {
this.debug('PIN disable result ' + result);
if(result === '9000') {
this._simPinState = PIN_STATE.DISABLED;
//this._view.hide();
return true;
}
return false;
},
_formatPin: function(pin) {
var pinBytes = pin.split('').map((d) => d.charCodeAt());
for(var i = pinBytes.length; i < PIN_DEFAULTS.MAX_SIM_PIN_LEN; i++) {
pinBytes.push(PIN_DEFAULTS.PIN_PADDING);
}
return pinBytes;
}
};
exports.PinController = PinController;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,274 @@
'use strict';
/* globals addMixin, ObserverSubjectMixin, DebugMixin */
/* exported PinView */
(function(exports) {
// number of pin input attempts which should
// trigger the pin warning display
const PIN_ATTEMPTS_WARNING = 2;
const PIN_INCORRECT_TIMEOUT = 1000; //ms
const PIN_DEFAULT_LEN = 4;
const PIN_REQ_DEF = 'request-default-pin';
const PIN_REQ_BEFORE_CHANGE = 'request-pin-before-change';
const PIN_REQ_PUK = 'request-puk';
const PIN_REQ_NEW_PIN = 'request-new-pin';
const PIN_REQ_REPEAT_NEW_PIN = 'request-repeat-new-pin';
const PIN_ENTRY_TEXT_MAP = {};
PIN_ENTRY_TEXT_MAP[PIN_REQ_DEF] = {
entry: 'Please enter your PIN',
error: 'Incorrect PIN'
};
PIN_ENTRY_TEXT_MAP[PIN_REQ_BEFORE_CHANGE] = {
entry: 'Please enter current PIN',
error: 'Incorrect PIN'
},
PIN_ENTRY_TEXT_MAP[PIN_REQ_PUK] = {
entry: 'Please enter your PUK',
error: 'Incorrect PUK'
};
PIN_ENTRY_TEXT_MAP[PIN_REQ_NEW_PIN] = {
entry: 'Please enter new PIN',
error: 'New PIN mismatch',
};
PIN_ENTRY_TEXT_MAP[PIN_REQ_REPEAT_NEW_PIN] = {
entry: 'Please confirm new PIN',
error: ''
};
var PinView = function(id, defPinLen, templateId) {
this._id = id;
this._defaultPinLen = defPinLen || PIN_DEFAULT_LEN;
this._maxPinLen = this._defaultPinLen;
addMixin(this, ObserverSubjectMixin);
addMixin(this, DebugMixin);
if(templateId) {
this._el = document.querySelector('#' + templateId).cloneNode(true);
this._el.id = this._id;
} else {
this._el = document.querySelector('#' + this._id);
}
this._init();
};
PinView.prototype = {
REQUEST_PIN: PIN_REQ_DEF,
REQUEST_BEFORE_CHANGE: PIN_REQ_BEFORE_CHANGE,
REQUEST_PUK: PIN_REQ_PUK,
REQUEST_NEW_PIN: PIN_REQ_NEW_PIN,
REQUEST_CONFIRM: PIN_REQ_REPEAT_NEW_PIN,
_el: null,
_id: null,
_visible: false,
_pinInputEl: null,
_inputedPin: '',
// we have a default PIN length but we can do one time request for PINs
// of different length (i.e. PUK)
_defaultPinLen: 0,
_maxPinLen: 0,
_inputBlocked: false,
isVisible() {
return this._visible;
},
show: function() {
this._visible = true;
this._el.classList.remove('hide');
this._clearPin();
},
hide: function() {
this._visible = false;
this._el.classList.add('hide');
},
_init: function() {
this._pinInputEl = this._el.querySelector('.pin-input');
var keyHandler = this._keyHandler.bind(this);
var keypad = this._el.querySelector('.pin-keypad');
keypad.addEventListener('touchstart', keyHandler, true);
this._updatePinInput();
},
_keyHandler: function(event) {
event.preventDefault();
event.stopPropagation();
var key = event.target.dataset.value;
if(!key || this._inputBlocked) {
return;
}
switch(key) {
case 'clear': this._clearPin(); break;
case 'backspace': this._backspacePin(); break;
default: this._appendPin(key);
}
},
_clearPin: function() {
this._inputedPin = '';
this._updatePinInput();
},
_backspacePin: function() {
if(!this._inputedPin.length) {
return;
}
this._inputedPin = this._inputedPin.slice(0, -1);
this._updatePinInput();
},
_appendPin: function(digit) {
if(this._inputedPin.length >= this._maxPinLen) {
return;
}
this._inputedPin += digit;
this._updatePinInput();
if(this._inputedPin.length === this._maxPinLen) {
if(this._maxPinLen !== this._defaultPinLen) {
this._maxPinLen = this._defaultPinLen;
}
this._notify({ action: 'pin-inputed', pin: this._inputedPin });
}
},
_updatePinInput: function() {
var inputedDigits = this._inputedPin.length;
var fragment = document.createDocumentFragment();
for(var i = 0; i < this._maxPinLen; i++) {
var pinDigit = document.createElement('div');
if(i < inputedDigits) {
pinDigit.classList.add('pin-digit-entered');
}
fragment.appendChild(pinDigit);
}
this._pinInputEl.innerHTML = '';
this._pinInputEl.appendChild(fragment);
},
_updatePinInputTexts: function _updatePinInputTexts(reqType) {
reqType = (reqType in PIN_ENTRY_TEXT_MAP) ? reqType : PIN_REQ_DEF;
this._el.querySelector('.pin-entry-text')
.textContent = PIN_ENTRY_TEXT_MAP[reqType].entry;
this._el.querySelector('.pin-entry-error-text')
.textContent = PIN_ENTRY_TEXT_MAP[reqType].error;
},
requestPin: function(attemptsLeft, showPinIncorrect, reqType, pinLen) {
this.debug('requestPIN, tries: ' + attemptsLeft + ', type: ' + reqType);
this._maxPinLen = pinLen || this._maxPinLen;
this._clearPin();
this._updatePinInputTexts(reqType);
this._showByClass('pin-unblocked');
if(showPinIncorrect) {
this._showPinIncorrect();
if(reqType !== PIN_REQ_NEW_PIN) {
this._showWarning(attemptsLeft);
}
} else if(attemptsLeft <= PIN_ATTEMPTS_WARNING) {
this._showWarning(attemptsLeft);
} else {
this._clearWarning();
}
},
cancelPinRequest: function() {
this._maxPinLen = this._defaultPinLen;
this._notify({});
},
blockPin: function() {
window.navigator.vibrate([200, 100, 200, 100, 200]);
this._showByClass('pin-blocked');
return new Promise((resolve, reject) => {
var unblockBtn = this._el.querySelector('.pin-unblock-btn');
var onclick = () => {
unblockBtn.removeEventListener('click', onclick);
resolve();
};
unblockBtn.addEventListener('click', onclick);
});
},
blockPuk: function() {
window.navigator.vibrate([200, 100, 200, 100, 200]);
this._showByClass('puk-blocked');
return new Promise(()=>{});
},
showEnableFailed: function(tries, defaultPin) {
window.navigator.vibrate([200, 100, 200, 100, 200]);
this._showByClass('pin-enable-error');
this._el.querySelector('.pin-default-val').textContent = defaultPin;
if(tries || tries === 0) {
this._el.querySelector('.pin-enable-warning').classList.remove('hide');
var att = (tries === 1) ? ' entry attempt' : ' entry attempts';
this._el.querySelector('.pin-enable-attempt').textContent = tries + att;
} else {
this._el.querySelector('.pin-enable-warning').classList.add('hide');
}
},
_showByClass: function(clss) {
var nodesList = this._el.querySelectorAll('article');
Array.prototype.slice.call(nodesList).forEach(el => {
if(el.classList.contains(clss)) {
el.classList.remove('hide');
} else {
el.classList.add('hide');
}
});
},
_showWarning: function(attemptsLeft) {
this._el.querySelector('.pin-warning').classList.remove('hide');
var warning = attemptsLeft + ' attempt';
warning = (attemptsLeft !== 1) ? (warning + 's') : warning;
warning += ' remaining';
this._el.querySelector('.pin-attempts').textContent = warning;
},
_togglePinIncorrect: function(show) {
this._inputBlocked = show;
this._el.querySelector('.pin-entry').classList.toggle('hide');
this._el.querySelector('.pin-incorrect').classList.toggle('hide');
},
_showPinIncorrect: function() {
window.navigator.vibrate([200, 100, 200]);
this._togglePinIncorrect(true);
setTimeout(() => this._togglePinIncorrect(false), PIN_INCORRECT_TIMEOUT);
},
_clearWarning: function() {
this._el.querySelector('.pin-warning').classList.add('hide');
}
};
exports.PinView = PinView;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,116 @@
/*
CRS AID, TAGS and APDUs based on:
GlobalPlatform Card Technology Contactless Services Card Specification v2.2
– Amendment C Version 1.1.1
UICC APDUs based on:
ETSI TS 102 221 V8.2.0 (2009-06)
Smart Cards; UICC-Terminal interface; Physical and logical characteristics
(Release 8)
*/
/* globals SEUtils */
/* exported APDU, AID, TAG */
'use strict';
(function(exports) {
const AID = {
CRS: 'A00000015143525300',
UICC: 'A0000000871001FF33FFFF8901010100'
};
const APDU = {
// gets the response from SIM, can be used to retireve channel select
getResponse: { cla: 0x00, ins: 0xC0, p1: 0x00, p2: 0x00 },
readBinary: { cla: 0x00, ins: 0xB0, p1: 0x00, p2: 0x00 },
CRS: {
// Get applet version number and global update counter
getData: { cla: 0x80, ins: 0xCA, p1: 0x00, p2: 0xA5 },
// Get status of applets and Security Domains only.
// The ISD is ignored. Get first or only occurrence
getStatusAll1st: {
cla: 0x80, ins: 0xF2, p1: 0x40, p2: 0x00,
data: new Uint8Array([
0x4F, 0x00, // applet AID, empty here
0x5C, 0x03, // tag list to be returned
0x4F, // AID
0x9F, 0x70 // applet life cycle state - first byte;
// contactless activation state - second byte
])
},
// Get status of applets and Security Domains only.
// The ISD is ignored. Get next occurrence(s), if SW is 63h10h
getStatusAllNext: { cla: 0x80, ins: 0xF2, p1: 0x40, p2: 0x01,
data: new Uint8Array([0x4F, 0x00, 0x5C, 0x03, 0x4F, 0x9F, 0x70]) },
// Get status of applet identified with AID
getStatusAID: (aid) => {
return { cla: 0x80, ins: 0xF2, p1: 0x40, p2: 0x00,
data: SEUtils.joinUint8Arrays(
[0x4F, 0x0F], // applet AID
SEUtils.hexStringToByte(aid),
[0x5C, 0x04, // tag list
0x4F, // AID
0x9F, // applet lifecycle state
0x70, // contactless activation state
0x81 // selection priority
]
)
};
},
activateCLF: (aid) => {
return { cla: 0x80, ins: 0xF0, p1: 0x01, p2: 0x01,
data: SEUtils.joinUint8Arrays([0x4F, 0x0F],
SEUtils.hexStringToByte(aid))
};
},
deactivateCLF: (aid) => {
return { cla: 0x80, ins: 0xF0, p1: 0x01, p2: 0x00,
data: SEUtils.joinUint8Arrays([0x4F, 0x0F],
SEUtils.hexStringToByte(aid))
};
},
nfcActivate: { cla: 0x80, ins: 0xF0, p1: 0x04, p2: 0x80,
data: new Uint8Array([0x80, 0x01, 0x40]) },
nfcDeactivate: { cla: 0x80, ins: 0xF0, p1: 0x04, p2: 0x00,
data: new Uint8Array([0x80, 0x01, 0x40]) }
},
// PIN commands
UICC: {
getAttempts: (p2) => {
return { cla: 0x00, ins: 0x20, p1: 0x00, p2: p2 };
},
verify: (p2, pinBytes) => {
return { cla: 0x00, ins: 0x20, p1: 0x00, p2: p2, data: pinBytes };
},
enable: (p2, defPinBytes) => {
return { cla: 0x00, ins: 0x28, p1: 0x00, p2: p2, data: defPinBytes };
},
disable: (p2, defPinBytes) => {
return { cla: 0x00, ins: 0x26, p1: 0x00, p2: p2, data: defPinBytes };
},
change: (p2, curPinBytes, newPinBytes) => {
var data = SEUtils.joinUint8Arrays(curPinBytes, newPinBytes);
return { cla: 0x00, ins: 0x24, p1: 0x00, p2: p2, data: data };
},
unblock: (p2, pukBytes, defPinBytes) => {
var data = SEUtils.joinUint8Arrays(pukBytes, defPinBytes);
return { cla: 0x00, ins: 0x2C, p1: 0x00, p2: p2, data: data };
}
}
};
const TAG = {
CRS: {
APP_TEMPLATE: 0x61,
AID: 0x4F,
STATE: 0x9F70
},
};
exports.AID = AID;
exports.APDU = APDU;
exports.TAG = TAG;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,90 @@
'use strict';
/* exported SEUtils */
(function(exports) {
var SEUtils = {
byteToHexString: function byteToHexString(uint8arr) {
if (!uint8arr) {
return '';
}
var hexStr = '';
for (var i = 0; i < uint8arr.length; i++) {
var hex = (uint8arr[i] & 0xff).toString(16);
hex = (hex.length === 1) ? '0' + hex : hex;
hexStr += hex;
}
return hexStr.toUpperCase();
},
hexStringToByte: function hexStringToByte(str) {
var a = [];
for(var i = 0, len = str.length; i < len; i+=2) {
a.push(parseInt(str.substr(i,2),16));
}
return new Uint8Array(a);
},
byteToASCIIString: function byteToASCIIString(arr) {
var str = '';
var len = arr && arr.length ? arr.length : 0;
for(var i = 0; i < len; i++) {
str += arr[i] < 0x20 || arr > 0x7E ? '' : String.fromCharCode(arr[i]);
}
return str;
},
joinUint8Arrays: function joinUint8Arrays() {
var args = Array.prototype.slice.call(arguments);
var length = args.reduce(function(a, b) { return a + b.length; }, 0);
var out = new Uint8Array(length);
args.reduce(function(previousLen, buffer) {
out.set(buffer, previousLen);
return previousLen + buffer.length;
}, 0);
return out;
},
// no support for multibyte tag and constructed data object
parseSimpleTLV: function parseSimpleTLV(data) {
if(!data) {
return null;
}
var result = {};
for(var i = 0, len = data.length; i < len;) {
var tag = data[i];
var length = data[i+1];
var value = data.slice(i+2, i+2 + length);
result[tag] = value;
i += 2 + length;
}
return result;
},
promises: {
whilePromise: function(condition, action, init) {
return new Promise((resolve, reject) => {
var loop = (value) => {
if (condition(value)) {
return resolve(value);
}
return action(value).then(loop).catch(reject);
};
loop(init);
});
},
},
};
exports.SEUtils = SEUtils;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,187 @@
'use strict';
/* globals SEUtils, APDU, ERRORS, DEFAULTS */
/* exported simAccessManager */
(function(exports) {
const SIM_ACCESS_DISABLED = 'disabled';
const SIM_ACCESS_IDLE = 'idle';
const SIM_ACCESS_BUSY = 'busy';
var _simState = SIM_ACCESS_DISABLED;
var _seSession = null;
var _crsAid = DEFAULTS.CRS_AID;
var _uiccAid = DEFAULTS.UICC_AID;
var _pinP2 = DEFAULTS.PIN_P2;
var SIMAccessManager = {
set crsAid(value) {
_crsAid = value;
},
get crsAidBytes() {
return SEUtils.hexStringToByte(_crsAid);
},
set uiccAid(value) {
_uiccAid = value;
},
get uiccAidBytes() {
return SEUtils.hexStringToByte(_uiccAid);
},
set pinP2(value) {
_pinP2 = value;
},
get pinP2Bytes() {
return SEUtils.hexStringToByte(_pinP2)[0];
},
start: function start() {
if(!navigator.seManager) {
return Promise.reject(ERRORS.GLOBAL.NO_API);
}
return navigator.seManager.getSEReaders()
.then((readers) => {
var reader = readers.find(r => r.type.toLowerCase() === 'uicc' && r.isSEPresent);
if(!reader) {
return Promise.reject(ERRORS.SIM.NO_READER);
}
return reader.openSession();
})
.then((session) => {
_seSession = session;
_simState = SIM_ACCESS_IDLE;
})
.catch((e) => {
console.error(e);
_simState = SIM_ACCESS_DISABLED;
if(e !== ERRORS.SIM.NO_READER) {
e = ERRORS.SIM.ACCESS_FAILED;
}
return Promise.reject(e);
});
},
stop: function stop() {
return _seSession.closeAll()
.then(() => {
_seSession = null;
_simState = SIM_ACCESS_DISABLED;
})
.catch(e => this._cleanup(e, ERRORS.SIM.SIM_FAILURE));
},
_initSIMAccess: function _initSIMAccess() {
if(_simState !== SIM_ACCESS_IDLE) {
return Promise.reject(ERRORS.SIM.NOT_IDLE);
}
if(!_seSession.reader.isSEPresent) {
return Promise.reject(ERRORS.SIM.NO_READER);
}
_simState = SIM_ACCESS_BUSY;
return Promise.resolve();
},
_cleanup: function _cleanup(error, message) {
console.error(error);
_simState = SIM_ACCESS_IDLE;
if(error === ERRORS.SIM.NOT_IDLE || error === ERRORS.SIM.NO_READER) {
message = error;
}
return Promise.reject(message);
},
getAppletsData: function getAppletsData() {
return this._initSIMAccess()
.then(() => _seSession.openLogicalChannel(this.crsAidBytes))
.then((channel) => {
return channel.transmit(APDU.CRS.getStatusAll1st);
})
.then((resp) => {
var receivedData = new Uint8Array();
// we can get different responses, also propriatary
// using counter to not allow to try to query the SIM indifinately
var count = 0;
var condition = (resp) => (count++ > 10 || resp.sw1 === 0x90 && resp.sw2 === 0x00);
var action = (resp) => {
receivedData = SEUtils.joinUint8Arrays(receivedData, resp.data);
return resp.channel.transmit(APDU.CRS.getStatusAllNext);
};
return SEUtils.promises.whilePromise(condition, action, resp)
.then((lastResp) => {
lastResp.channel.close();
_simState = SIM_ACCESS_IDLE;
return SEUtils.joinUint8Arrays(receivedData, lastResp.data);
});
})
.catch(e => this._cleanup(e, ERRORS.SIM.APPLETS_FAILURE));
},
toggleCLF: function toggleCLF(on, aid) {
var apdu = on ? APDU.CRS.activateCLF(aid) : APDU.CRS.deactivateCLF(aid);
return this._initSIMAccess()
.then(() => _seSession.openLogicalChannel(this.crsAidBytes))
.then((channel) => channel.transmit(apdu))
.then((resp) => {
return resp.channel.close()
.then(() => {
_simState = SIM_ACCESS_IDLE;
return resp.sw1 === 0x90 && resp.sw2 === 0x00;
});
})
.catch(e => this._cleanup(e, ERRORS.SIM.SIM_FAILURE));
},
_execPinCommand: function _execPinCommand(type, pin1, pin2) {
return this._initSIMAccess()
.then(() => _seSession.openLogicalChannel(this.uiccAidBytes))
.then((channel) => {
var apdu = APDU.UICC[type](this.pinP2Bytes, pin1, pin2);
if(!apdu) {
return Promise.reject('PIN APDU type ' + type + ' not found');
}
return channel.transmit(apdu);
})
.then((resp) => {
return resp.channel.close().then(() => {
_simState = SIM_ACCESS_IDLE;
return SEUtils.byteToHexString([resp.sw1, resp.sw2]);
});
})
.catch(e => this._cleanup(e, ERRORS.SIM.PIN_FAILURE));
},
verifyPIN: function verifyPIN(pin) {
return this._execPinCommand('verify', pin);
},
enablePIN: function enablePIN(pin) {
return this._execPinCommand('enable', pin);
},
unblockPIN: function unblockPIN(puk, pin) {
return this._execPinCommand('unblock', puk, pin);
},
disablePIN: function disablePIN(pin) {
return this._execPinCommand('disable', pin);
},
changePIN: function changePIN(currentPin, newPin) {
return this._execPinCommand('change', currentPin, newPin);
},
};
exports.SIMAccess = SIMAccessManager;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,66 @@
'use strict';
/* exported ConfigSubView */
(function(exports) {
var ConfigSubView = function(id, crsAid, uiccAid, pinP2, defaultPin) {
this._id = id;
this._el = document.querySelector('#'+id);
this.crsInput = this._el.querySelector('#crs-aid');
this.crsInput.value = crsAid;
this.uiccInput = this._el.querySelector('#uicc-aid');
this.uiccInput.value = uiccAid;
this.pinP2Input = this._el.querySelector('#pinP2');
this.pinP2Input.value = pinP2;
this.defaultPinInput = this._el.querySelector('#default-pin');
this.defaultPinInput.value = defaultPin;
this._el.querySelector('header button.icon-back')
.addEventListener('click', () => this._handleConfigFinished());
this._el.querySelector('#settings-configuration-done')
.addEventListener('click', () => this._handleConfigFinished());
};
ConfigSubView.prototype = {
_el: null,
_id: null,
_visible: false,
crsInput: null,
uiccInput: null,
pinP2Input: null,
defaultPinInput: null,
get visible() {
return this._visible;
},
set visible(value) {
if(value) {
this._visible = true;
this._el.classList.add('slide-in');
} else {
this._visible = false;
this._el.classList.remove('slide-in');
}
},
_handleConfigFinished: function() {
if(!this.crsInput.validity.valid || !this.uiccInput.validity.valid ||
!this.pinP2Input.validity.valid ||
!this.defaultPinInput.validity.valid) {
this.debug('Inputs not valid, not closing settings');
return;
}
this.visible = false;
},
};
exports.ConfigSubView = ConfigSubView;
}(window));

View file

@ -0,0 +1,35 @@
'use strict';
/* globals AID */
/* exported DEFAULTS, SIM_REFRESH, SIM_UPDATE */
(function(exports) {
const DEFAULTS = {
PIN: false,
FASTPAY: true,
CRS_AID: AID.CRS,
UICC_AID: AID.UICC,
PIN_P2: '0E',
PIN_VALUE: '0000'
};
// settings properties which trigger sim data refresh on change
const SIM_REFRESH = ['crsAid'];
// settings properties which trigger SIMAccessManager update
const SIM_UPDATE = ['crsAid', 'uiccAid', 'pinP2'];
const PIN_DEFAULTS = {
MAX_PIN_ATTEMPTS: 3,
MAX_PUK_ATTEMPTS: 10,
PIN_LEN: 4,
MAX_SIM_PIN_LEN: 8,
PIN_PADDING: 255
};
exports.DEFAULTS = DEFAULTS;
exports.SIM_REFRESH = SIM_REFRESH;
exports.SIM_UPDATE = SIM_UPDATE;
exports.PIN_DEFAULTS = PIN_DEFAULTS;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,196 @@
'use strict';
/* globals addMixin, DebugMixin, ObserverSubjectMixin, SettingsView, DEFAULTS */
/* exported SettingsController */
(function(exports) {
var SettingsController = function(id, viewId, pinCtrl) {
this._id = id;
this._pinCtrl = pinCtrl;
addMixin(this, DebugMixin);
addMixin(this, ObserverSubjectMixin);
this._init(viewId);
};
SettingsController.prototype = {
_id: null,
_view: null,
_settingsData: {
'pin': DEFAULTS.PIN,
'fastPay': DEFAULTS.FASTPAY,
'crsAid': DEFAULTS.CRS_AID,
'uiccAid': DEFAULTS.UICC_AID,
'pinP2': DEFAULTS.PIN_P2,
'defaultPin': DEFAULTS.PIN_VALUE
},
get pinEnabled() {
return this._settingsData.pin;
},
get fastpayEnabled() {
return this._settingsData.fastPay;
},
get crsAid() {
return this._settingsData.crsAid;
},
get uiccAid() {
return this._settingsData.uiccAid;
},
get pinP2() {
return this._settingsData.pinP2;
},
get defaultPin() {
return this._settingsData.defaultPin;
},
show: function() {
this._view.visible = true;
},
hide: function() {
this._view.visible = false;
},
_init: function(viewId) {
Object.keys(this._settingsData).forEach(s => {
this._settingsData[s] = this._readSetting(s, this._settingsData[s]);
});
this._view = new SettingsView(viewId, Object.create(this._settingsData));
this._view.addListener(this);
this._view.setSubViewContent('settings-pin', this._pinCtrl.view);
},
_readSetting: function(settingName, defValue) {
var settingVal = localStorage.getItem(settingName);
if(settingVal === 'true' || settingVal === 'false') {
settingVal = (settingVal === 'true');
}
if(settingVal === null && defValue !== undefined) {
localStorage.setItem(settingName, defValue);
settingVal = defValue;
}
return settingVal;
},
onEvent: function(id, event) {
this.debug('Event from: ' + id + ', ' + JSON.stringify(event));
switch(event.action) {
case 'editing-finished': this._handleInput(event.data); break;
case 'pin-toggle': this._handlePinToggle(event.data.checked); break;
case 'pin-change': this._handlePinChange(); break;
case 'pin-cancel': this._handlePinCancel(); break;
}
},
addPinComponent: function(pinCtrl) {
this._pinCtrl = pinCtrl;
this._view.setSubViewContent('settings-pin', this._pinCtrl.view);
},
_handlePinToggle: function(enable) {
this._view.showSubview('settings-pin');
if(enable) {
this._handlePinEnable();
} else {
this._handlePinDisable();
}
},
_handlePinEnable: function() {
this._pinCtrl.enablePin()
.then((enabled) => {
this._view.pinEnabled = enabled;
if(enabled) {
this._view.hideSubview('settings-pin');
this._pin = true;
localStorage.setItem('pin', this._pin);
this._view.showStatus('Wallet PIN enabled successfully');
} else {
this.debug('PIN enable failure');
this._view.showStatus('Failed to enable Wallet PIN');
}
})
.catch(e => {
if(e !== 'pin-cancelled') {
this._view.hideSubview('settings-pin');
this._notify({ error: e, msg: 'PIN enable error' });
}
});
},
_handlePinDisable: function() {
this._pinCtrl.disablePin()
.then((disabled) => {
this._view.hideSubview('settings-pin');
this._view.pinEnabled = !disabled;
if(disabled) {
this._pin = false;
localStorage.setItem('pin', this._pin);
this._view.showStatus('Wallet PIN disabled successfully');
} else {
this.debug('PIN disable failure');
this._view.showStatus('Failed to disable Wallet PIN');
}
})
.catch(e => {
if(e !== 'pin-cancelled') {
this._view.hideSubview('settings-pin');
this._notify({ error: e, msg: 'PIN disable error' });
}
});
},
_handlePinChange: function() {
this._view.showSubview('settings-pin');
this._pinCtrl.changePin()
.then((success) => {
this._view.hideSubview('settings-pin');
if(success) {
this._view.showStatus('Wallet PIN changed successfully');
} else {
this.debug('Change failed');
this._view.showStatus('Failed to change Wallet PIN');
}
})
.catch(e => {
if(e !== 'pin-cancelled') {
this._view.hideSubview('settings-pin');
this._notify({ error: e, msg: 'PIN chnage error' });
}
});
},
_handlePinCancel() {
this._pinCtrl.cancelPinAction();
},
_handleInput: function(data) {
var changes = {};
Object.keys(data).forEach(s => {
if(this._settingsData[s] !== data[s]) {
this._settingsData[s] = data[s];
changes[s] = data[s];
localStorage.setItem(s, data[s]);
}
});
if(Object.keys(changes).length) {
this._notify({ action: 'settings-updated', changes: changes });
}
},
};
exports.SettingsController = SettingsController;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,49 @@
'use strict';
/* exported SettingsSubView */
(function(exports) {
var SettingsSubView = function(id, onCloseCb) {
this._id = id;
this._onCloseCallback = onCloseCb;
this._el = document.querySelector('#'+id);
this._el.querySelector('header button.icon-back')
.addEventListener('click', () => {
this.visible = false;
if(this._onCloseCallback) {
this._onCloseCallback();
}
});
};
SettingsSubView.prototype = {
_el: null,
_id: null,
_visible: false,
_onCloseCallback: null,
get visible() {
return this._visible;
},
set visible(value) {
if(value) {
this._visible = true;
this._el.classList.add('slide-in');
} else {
this._visible = false;
this._el.classList.remove('slide-in');
}
},
set content(el) {
var contentEl = this._el.querySelector('.subview-content');
this._el.replaceChild(el, contentEl);
el.classList.add('subview-content');
},
};
exports.SettingsSubView = SettingsSubView;
}((typeof exports === 'undefined') ? window : exports));

View file

@ -0,0 +1,152 @@
'use strict';
/* globals addMixin, ObserverSubjectMixin, DebugMixin, SettingsSubView,
ConfigSubView */
/* exported SettingsView */
(function(exports) {
const STATUS_TIMEOUT = 4000; // ms
var SettingsView = function(id, options) {
this._id = id;
this._el = document.querySelector('#' + id);
this._initMainView(options.pin, options.fastPay);
this._initSubViews(options);
addMixin(this, ObserverSubjectMixin);
addMixin(this, DebugMixin);
};
SettingsView.prototype = {
_el: null,
_id: null,
_visible: false,
_subviews: [],
_pinCheckbox: null,
_fastPayCheckbox: null,
_pinChangeBtn: null,
_pinEnabled: false,
get visible() {
return this._visible;
},
set visible(value) {
if(value) {
this._visible = true;
this._el.classList.remove('hide');
this._el.classList.add('edit');
} else {
this._subviews.forEach((v) => { v.visible = false; });
this._visible = false;
this._el.classList.remove('edit');
}
},
set pinEnabled(value) {
this._pinCheckbox.checked = value;
if(value) {
this._pinChangeBtn.classList.remove('settings-disabled');
} else {
this._pinChangeBtn.classList.add('settings-disabled');
}
},
_initMainView: function(pin, fastPay) {
this._pinCheckbox = this._el.querySelector('#pin-checkbox');
this._pinCheckbox.addEventListener('change', () => {
this._notify({
action: 'pin-toggle',
data: { checked: this._pinCheckbox.checked }
});
this.pinEnabled = !this._pinCheckbox.checked;
});
this._pinChangeBtn = this._el.querySelector('#pin-change-btn');
this._pinChangeBtn.addEventListener('click', () => {
this._notify({ action: 'pin-change' });
});
this.pinEnabled = pin;
this._fastPayCheckbox = this._el.querySelector('#fast-pay');
this._fastPayCheckbox.checked = fastPay;
this._el.querySelector('#settings-done').addEventListener('click',
() => this._handleEditingFinished());
},
_initSubViews: function(cfg){
var config = new ConfigSubView('settings-configuration', cfg.crsAid,
cfg.uiccAid, cfg.pinP2, cfg.defaultPin);
this._el.querySelector('#adv-settings-btn')
.addEventListener('click', () => { config.visible = true; });
var about = new SettingsSubView('settings-about');
this._el.querySelector('#about-btn')
.addEventListener('click', () => { about.visible = true; });
var pinCloseHandler = () => this._handlePinViewClosed();
var pin = new SettingsSubView('settings-pin', pinCloseHandler);
this._subviews.push(config);
this._subviews.push(about);
this._subviews.push(pin);
},
_getSubView: function(id) {
return this._subviews.find(v => v._id === id);
},
setSubViewContent: function(id, content) {
var subView = this._getSubView(id);
if(subView) {
subView.content = content;
}
},
showSubview: function(id, hide) {
var subView = this._getSubView(id);
if(subView) {
subView.visible = !hide;
}
},
hideSubview: function(id) {
this.showSubview(id, true);
},
showStatus: function(statusMsg) {
this._el.querySelector('#settings-status p').textContent = statusMsg;
var status = this._el.querySelector('#settings-status');
status.classList.add('visible');
setTimeout(() => status.classList.remove('visible'), STATUS_TIMEOUT);
},
_handleEditingFinished: function _handleEditingFinished() {
var conf = this._getSubView('settings-configuration');
this._notify({
action: 'editing-finished',
data: {
pin: this._pinCheckbox.checked,
fastPay: this._fastPayCheckbox.checked,
crsAid: conf.crsInput.value,
uiccAid: conf.uiccInput.value,
pinP2: conf.pinP2Input.value,
defaultPin: conf.defaultPinInput.value
}
});
this.visible = false;
},
_handlePinViewClosed: function _handlePinViewClosed() {
this._notify({ action: 'pin-cancel' });
}
};
exports.SettingsView = SettingsView;
}((typeof exports === 'undefined') ? window : exports));