/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.sync.repositories.domain; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Map; import org.json.simple.JSONArray; import org.mozilla.gecko.sync.ExtendedJSONObject; import org.mozilla.gecko.sync.Logger; import org.mozilla.gecko.sync.NonArrayJSONException; import org.mozilla.gecko.sync.Utils; import org.mozilla.gecko.sync.repositories.android.RepoUtils; /** * Covers the fields used by all bookmark objects. * @author rnewman * */ public class BookmarkRecord extends Record { public static final String PLACES_URI_PREFIX = "places:"; private static final String LOG_TAG = "BookmarkRecord"; public static final String COLLECTION_NAME = "bookmarks"; public static final long BOOKMARKS_TTL = -1; // Never ttl bookmarks. public BookmarkRecord(String guid, String collection, long lastModified, boolean deleted) { super(guid, collection, lastModified, deleted); this.ttl = BOOKMARKS_TTL; } public BookmarkRecord(String guid, String collection, long lastModified) { this(guid, collection, lastModified, false); } public BookmarkRecord(String guid, String collection) { this(guid, collection, 0, false); } public BookmarkRecord(String guid) { this(guid, COLLECTION_NAME, 0, false); } public BookmarkRecord() { this(Utils.generateGuid(), COLLECTION_NAME, 0, false); } // Note: redundant accessors are evil. We're all grownups; let's just use // public fields. public String title; public String bookmarkURI; public String description; public String keyword; public String parentID; public String parentName; public long androidParentID; public String type; public long androidPosition; public JSONArray children; public JSONArray tags; @Override public String toString() { return "#<Bookmark " + guid + " (" + androidID + "), parent " + parentID + "/" + androidParentID + "/" + parentName + ">"; } // Oh God, this is terribly thread-unsafe. These record objects should be immutable. @SuppressWarnings("unchecked") protected JSONArray copyChildren() { if (this.children == null) { return null; } JSONArray children = new JSONArray(); children.addAll(this.children); return children; } @SuppressWarnings("unchecked") protected JSONArray copyTags() { if (this.tags == null) { return null; } JSONArray tags = new JSONArray(); tags.addAll(this.tags); return tags; } @Override public Record copyWithIDs(String guid, long androidID) { BookmarkRecord out = new BookmarkRecord(guid, this.collection, this.lastModified, this.deleted); out.androidID = androidID; out.sortIndex = this.sortIndex; out.ttl = this.ttl; // Copy BookmarkRecord fields. out.title = this.title; out.bookmarkURI = this.bookmarkURI; out.description = this.description; out.keyword = this.keyword; out.parentID = this.parentID; out.parentName = this.parentName; out.androidParentID = this.androidParentID; out.type = this.type; out.androidPosition = this.androidPosition; out.children = this.copyChildren(); out.tags = this.copyTags(); return out; } public boolean isBookmark() { if (type == null) { return false; } return type.equals("bookmark"); } public boolean isFolder() { if (type == null) { return false; } return type.equals("folder"); } public boolean isLivemark() { if (type == null) { return false; } return type.equals("livemark"); } public boolean isSeparator() { if (type == null) { return false; } return type.equals("separator"); } public boolean isMicrosummary() { if (type == null) { return false; } return type.equals("microsummary"); } public boolean isQuery() { if (type == null) { return false; } return type.equals("query"); } /** * Return true if this record should have the Sync fields * of a bookmark, microsummary, or query. */ private boolean isBookmarkIsh() { if (type == null) { return false; } return type.equals("bookmark") || type.equals("microsummary") || type.equals("query"); } @Override protected void initFromPayload(ExtendedJSONObject payload) { this.type = payload.getString("type"); this.title = payload.getString("title"); this.description = payload.getString("description"); this.parentID = payload.getString("parentid"); this.parentName = payload.getString("parentName"); if (isFolder()) { try { this.children = payload.getArray("children"); } catch (NonArrayJSONException e) { Logger.error(LOG_TAG, "Got non-array children in bookmark record " + this.guid, e); // Let's see if we can recover later by using the parentid pointers. this.children = new JSONArray(); } return; } final String bmkUri = payload.getString("bmkUri"); // bookmark, microsummary, query. if (isBookmarkIsh()) { this.keyword = payload.getString("keyword"); try { this.tags = payload.getArray("tags"); } catch (NonArrayJSONException e) { Logger.warn(LOG_TAG, "Got non-array tags in bookmark record " + this.guid, e); this.tags = new JSONArray(); } } if (isBookmark()) { this.bookmarkURI = bmkUri; return; } if (isLivemark()) { String siteUri = payload.getString("siteUri"); String feedUri = payload.getString("feedUri"); this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri, "siteUri", siteUri, "feedUri", feedUri); return; } if (isQuery()) { String queryId = payload.getString("queryId"); String folderName = payload.getString("folderName"); this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri, "queryId", queryId, "folderName", folderName); return; } if (isMicrosummary()) { String generatorUri = payload.getString("generatorUri"); String staticTitle = payload.getString("staticTitle"); this.bookmarkURI = encodeUnsupportedTypeURI(bmkUri, "generatorUri", generatorUri, "staticTitle", staticTitle); return; } if (isSeparator()) { Object p = payload.get("pos"); if (p instanceof Long) { this.androidPosition = (Long) p; } else if (p instanceof String) { try { this.androidPosition = Long.parseLong((String) p, 10); } catch (NumberFormatException e) { return; } } else { Logger.warn(LOG_TAG, "Unsupported position value " + p); return; } String pos = String.valueOf(this.androidPosition); this.bookmarkURI = encodeUnsupportedTypeURI(null, "pos", pos, null, null); return; } } @Override protected void populatePayload(ExtendedJSONObject payload) { putPayload(payload, "type", this.type); putPayload(payload, "title", this.title); putPayload(payload, "description", this.description); putPayload(payload, "parentid", this.parentID); putPayload(payload, "parentName", this.parentName); putPayload(payload, "keyword", this.keyword); if (isFolder()) { payload.put("children", this.children); return; } // bookmark, microsummary, query. if (isBookmarkIsh()) { if (isBookmark()) { payload.put("bmkUri", bookmarkURI); } if (isQuery()) { Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); putPayload(payload, "queryId", parts.get("queryId"), true); putPayload(payload, "folderName", parts.get("folderName"), true); putPayload(payload, "bmkUri", parts.get("uri")); return; } if (this.tags != null) { payload.put("tags", this.tags); } putPayload(payload, "keyword", this.keyword); return; } if (isLivemark()) { Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); putPayload(payload, "siteUri", parts.get("siteUri")); putPayload(payload, "feedUri", parts.get("feedUri")); return; } if (isMicrosummary()) { Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); putPayload(payload, "generatorUri", parts.get("generatorUri")); putPayload(payload, "staticTitle", parts.get("staticTitle")); return; } if (isSeparator()) { Map<String, String> parts = Utils.extractURIComponents(PLACES_URI_PREFIX, this.bookmarkURI); String pos = parts.get("pos"); if (pos == null) { return; } try { payload.put("pos", Long.parseLong(pos, 10)); } catch (NumberFormatException e) { return; } return; } } private void trace(String s) { Logger.trace(LOG_TAG, s); } @Override public boolean equalPayloads(Object o) { trace("Calling BookmarkRecord.equalPayloads."); if (o == null || !(o instanceof BookmarkRecord)) { return false; } BookmarkRecord other = (BookmarkRecord) o; if (!super.equalPayloads(other)) { return false; } if (!RepoUtils.stringsEqual(this.type, other.type)) { return false; } // Check children. if (isFolder() && (this.children != other.children)) { trace("BookmarkRecord.equals: this folder: " + this.title + ", " + this.guid); trace("BookmarkRecord.equals: other: " + other.title + ", " + other.guid); if (this.children == null && other.children != null) { trace("Records differ: one children array is null."); return false; } if (this.children != null && other.children == null) { trace("Records differ: one children array is null."); return false; } if (this.children.size() != other.children.size()) { trace("Records differ: children arrays differ in size (" + this.children.size() + " vs. " + other.children.size() + ")."); return false; } for (int i = 0; i < this.children.size(); i++) { String child = (String) this.children.get(i); if (!other.children.contains(child)) { trace("Records differ: child " + child + " not found."); return false; } } } trace("Checking strings."); return RepoUtils.stringsEqual(this.title, other.title) && RepoUtils.stringsEqual(this.bookmarkURI, other.bookmarkURI) && RepoUtils.stringsEqual(this.parentID, other.parentID) && RepoUtils.stringsEqual(this.parentName, other.parentName) && RepoUtils.stringsEqual(this.description, other.description) && RepoUtils.stringsEqual(this.keyword, other.keyword) && jsonArrayStringsEqual(this.tags, other.tags); } // TODO: two records can be congruent if their child lists are different. @Override public boolean congruentWith(Object o) { return this.equalPayloads(o) && super.congruentWith(o); } // Converts two JSONArrays to strings and checks if they are the same. // This is only useful for stuff like tags where we aren't actually // touching the data there (and therefore ordering won't change) private boolean jsonArrayStringsEqual(JSONArray a, JSONArray b) { // Check for nulls if (a == b) return true; if (a == null && b != null) return false; if (a != null && b == null) return false; return RepoUtils.stringsEqual(a.toJSONString(), b.toJSONString()); } /** * URL-encode the provided string. If the input is null, * the empty string is returned. * * @param in the string to encode. * @return a URL-encoded version of the input. */ protected static String encode(String in) { if (in == null) { return ""; } try { return URLEncoder.encode(in, "UTF-8"); } catch (UnsupportedEncodingException e) { // Will never occur. return null; } } /** * Take the provided URI and two parameters, constructing a URI like * * places:uri=$uri&p1=$p1&p2=$p2 * * null values in either parameter or value result in the parameter being omitted. */ protected static String encodeUnsupportedTypeURI(String originalURI, String p1, String v1, String p2, String v2) { StringBuilder b = new StringBuilder(PLACES_URI_PREFIX); boolean previous = false; if (originalURI != null) { b.append("uri="); b.append(encode(originalURI)); previous = true; } if (p1 != null && v1 != null) { if (previous) { b.append("&"); } b.append(p1); b.append("="); b.append(encode(v1)); previous = true; } if (p2 != null && v2 != null) { if (previous) { b.append("&"); } b.append(p2); b.append("="); b.append(encode(v2)); previous = true; } return b.toString(); } } /* // Bookmark: {cleartext: {id: "l7p2xqOTMMXw", type: "bookmark", title: "Your Flight Status", parentName: "mobile", bmkUri: "http: //www.flightstats.com/go/Mobile/flightStatusByFlightProcess.do;jsessionid=13A6C8DCC9592AF141A43349040262CE.web3: 8009?utm_medium=cpc&utm_campaign=co-op&utm_source=airlineInformationAndStatus&id=212492593", tags: [], keyword: null, description: null, loadInSidebar: false, parentid: "mobile"}, data: {payload: {ciphertext: null}, id: "l7p2xqOTMMXw", sortindex: 107}, collection: "bookmarks"} // Folder: {cleartext: {id: "mobile", type: "folder", parentName: "", title: "mobile", description: null, children: ["1ROdlTuIoddD", "3Z_bMIHPSZQ8", "4mSDUuOo2iVB", "8aEdE9IIrJVr", "9DzPTmkkZRDb", "Qwwb99HtVKsD", "s8tM36aGPKbq", "JMTi61hOO3JV", "JQUDk0wSvYip", "LmVH-J1r3HLz", "NhgQlC5ykYGW", "OVanevUUaqO2", "OtQVX0PMiWQj", "_GP5cF595iie", "fkRssjXSZDL3", "k7K_NwIA1Ya0", "raox_QGzvqh1", "vXYL-xHjK06k", "QKHKUN6Dm-xv", "pmN2dYWT2MJ_", "EVeO_J1SQiwL", "7N-qkepS7bec", "NIGa3ha-HVOE", "2Phv1I25wbuH", "TTSIAH1fV0VE", "WOmZ8PfH39Da", "gDTXNg4m1AJZ", "ayI30OZslHbO", "zSEs4O3n6CzQ", "oWTDR0gO2aWf", "wWHUoFaInXi9", "F7QTuVJDpsTM", "FIboggegplk-", "G4HWrT5nfRYS", "MHA7y9bupDdv", "T_Ldzmj0Ttte", "U9eYu3SxsE_U", "bk463Kl9IO_m", "brUfrqJjFNSR", "ccpawfWsD-bY", "l7p2xqOTMMXw", "o-nSDKtXYln7"], parentid: "places"}, data: {payload: {ciphertext: null}, id: "mobile", sortindex: 1000000}, collection: "bookmarks"} */