Java tutorial
/* * Copyright 2014 Phil Bayfield * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package me.philio.ghost.sync; import android.accounts.Account; import android.accounts.AccountManager; import android.accounts.AuthenticatorException; import android.accounts.OperationCanceledException; import android.content.AbstractThreadedSyncAdapter; import android.content.ContentProviderClient; import android.content.Context; import android.content.Intent; import android.content.SyncResult; import android.net.Uri; import android.os.Bundle; import android.util.Log; import com.activeandroid.ActiveAndroid; import com.activeandroid.content.ContentProvider; import com.activeandroid.query.Select; import org.apache.commons.io.IOUtils; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import me.philio.ghost.account.AccountConstants; import me.philio.ghost.io.GhostClient; import me.philio.ghost.io.endpoint.Authentication; import me.philio.ghost.io.endpoint.Posts; import me.philio.ghost.io.endpoint.Settings; import me.philio.ghost.io.endpoint.Tags; import me.philio.ghost.io.endpoint.Users; import me.philio.ghost.model.Blog; import me.philio.ghost.model.Post; import me.philio.ghost.model.PostTag; import me.philio.ghost.model.PostsContainer; import me.philio.ghost.model.Setting; import me.philio.ghost.model.SettingsContainer; import me.philio.ghost.model.Tag; import me.philio.ghost.model.TagsContainer; import me.philio.ghost.model.Token; import me.philio.ghost.model.User; import me.philio.ghost.model.UsersContainer; import me.philio.ghost.util.DatabaseUtils; import me.philio.ghost.util.ImageUtils; import retrofit.RetrofitError; import static me.philio.ghost.account.AccountConstants.KEY_ACCESS_TOKEN_EXPIRES; import static me.philio.ghost.account.AccountConstants.TOKEN_TYPE_ACCESS; import static me.philio.ghost.account.AccountConstants.TOKEN_TYPE_REFRESH; import static me.philio.ghost.io.ApiConstants.CLIENT_ID; import static me.philio.ghost.io.ApiConstants.GRANT_TYPE_PASSWORD; import static me.philio.ghost.io.ApiConstants.GRANT_TYPE_REFRESH_TOKEN; import static me.philio.ghost.model.Post.Status; /** * Sync adapter * * Created by phil on 04/12/2014. */ public class SyncAdapter extends AbstractThreadedSyncAdapter { /** * Logging tag */ private static final String TAG = SyncAdapter.class.getName(); /** * Account manager */ private AccountManager mAccountManager; public SyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); mAccountManager = AccountManager.get(context); } @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { Log.d(TAG, "Sync started for " + account.name); Intent intent = new Intent(SyncConstants.ACTION_SYNC_STARTED); intent.putExtra(SyncConstants.EXTRA_ACCOUNT, account); getContext().sendBroadcast(intent); try { // Refresh the access token refreshAccessToken(account); // Sync remote changes syncRemote(account, syncResult); } catch (AuthenticatorException | OperationCanceledException | IOException | RetrofitError | NoSuchAlgorithmException e) { Log.e(TAG, "Sync error: " + e.getMessage()); syncResult.stats.numIoExceptions++; } Log.d(TAG, "Sync finished for " + account.name); Log.d(TAG, "Inserts: " + syncResult.stats.numInserts); Log.d(TAG, "Updates: " + syncResult.stats.numUpdates); Log.d(TAG, "Deletes: " + syncResult.stats.numDeletes); Log.d(TAG, "Errors: " + syncResult.hasError()); intent.setAction(SyncConstants.ACTION_SYNC_FINISHED); intent.putExtra(SyncConstants.EXTRA_RESULT, syncResult); getContext().sendBroadcast(intent); } /** * Access tokens only last 60 minutes so we need to manage this and refresh it frequently. If * token has less than 30 minutes remaining it will be refreshed and as a last resort we can * use the email/password combination that was saved on login to re-authenticate from scratch. * * TODO Review later */ private void refreshAccessToken(Account account) throws AuthenticatorException, OperationCanceledException, IOException, RetrofitError { // Check expiry first Long expiry = Long .parseLong(mAccountManager.getUserData(account, AccountConstants.KEY_ACCESS_TOKEN_EXPIRES)); if (System.currentTimeMillis() + (30 * 60 * 1000) < expiry) { Log.d(TAG, "Access token has more than 30 minutes remaining, won't refresh"); return; } // Get blog url and refresh token String blogUrl = mAccountManager.getUserData(account, AccountConstants.KEY_BLOG_URL); String refreshToken = mAccountManager.blockingGetAuthToken(account, TOKEN_TYPE_REFRESH, false); // Get authentication client GhostClient client = new GhostClient(blogUrl); Authentication authentication = client.createAuthentication(); try { // Request a new access token Token token = authentication.blockingGetAccessToken(GRANT_TYPE_REFRESH_TOKEN, CLIENT_ID, refreshToken); // Save new access token mAccountManager.setAuthToken(account, TOKEN_TYPE_ACCESS, token.accessToken); mAccountManager.setUserData(account, KEY_ACCESS_TOKEN_EXPIRES, Long.toString(System.currentTimeMillis() + (token.expires * 1000))); } catch (RetrofitError e) { // Check for a 401/403 as we can try and re-authenticate with an email/password if (e.getResponse() != null && (e.getResponse().getStatus() == HttpURLConnection.HTTP_UNAUTHORIZED || e.getResponse().getStatus() == HttpURLConnection.HTTP_FORBIDDEN)) { String email = mAccountManager.getUserData(account, AccountConstants.KEY_EMAIL); String password = mAccountManager.getPassword(account); Token token = authentication.blockingGetAccessToken(GRANT_TYPE_PASSWORD, CLIENT_ID, email, password); // Save new tokens mAccountManager.setAuthToken(account, TOKEN_TYPE_ACCESS, token.accessToken); mAccountManager.setAuthToken(account, TOKEN_TYPE_REFRESH, token.refreshToken); mAccountManager.setUserData(account, KEY_ACCESS_TOKEN_EXPIRES, Long.toString(System.currentTimeMillis() + (token.expires * 1000))); } else { // Rethrow the exception if something else went wrong throw e; } } } /** * Get blog data from the server, a pretty straight forward fetch all and replace approach for * now, but this is far from optimal and ideally needs to pull changes only but this * functionality doesn't yet look like it exists in the Ghost API. * * TODO Hopefully this can be optimised at a later date, depends on the API */ private void syncRemote(Account account, SyncResult syncResult) throws AuthenticatorException, OperationCanceledException, IOException, RetrofitError, NoSuchAlgorithmException { // Get user data String blogUrl = mAccountManager.getUserData(account, AccountConstants.KEY_BLOG_URL); String email = mAccountManager.getUserData(account, AccountConstants.KEY_EMAIL); // Load the blog record Blog blog = DatabaseUtils.getBlog(blogUrl, email); // If record is missing, recreate it if (blog == null) { blog = new Blog(); blog.url = blogUrl; blog.email = email; blog.save(); } // Get access token and instantiate the client String accessToken = mAccountManager.blockingGetAuthToken(account, TOKEN_TYPE_ACCESS, false); GhostClient client = new GhostClient(blog.url, accessToken); // Sync users Users users = client.createUsers(); int page = 1; int totalPages = 1; while (page <= totalPages) { UsersContainer usersContainer = users.blockingGetUsers(page); for (User user : usersContainer.users) { user.blog = blog; saveUser(user, syncResult); saveContent(blog, user.image, ContentProvider.createUri(User.class, user.getId())); saveContent(blog, user.cover, ContentProvider.createUri(User.class, user.getId())); } totalPages = usersContainer.meta.pagination.pages; page++; } // Sync settings (no pagination) Settings settings = client.createSettings(); SettingsContainer settingsContainer = settings.blockingGetSettings(Setting.Type.BLOG, 1); for (Setting setting : settingsContainer.settings) { setting.blog = blog; saveSetting(setting, syncResult); if (setting.key.equals(Setting.Key.LOGO) || setting.key.equals(Setting.Key.COVER)) { saveContent(blog, setting.value, ContentProvider.createUri(Setting.class, null)); } } // Sync tags Tags tags = client.createTags(); page = 1; totalPages = 1; while (page <= totalPages) { TagsContainer tagsContainer = tags.blockingGetTags(page); for (Tag tag : tagsContainer.tags) { tag.blog = blog; saveTag(tag, syncResult); } totalPages = tagsContainer.meta.pagination.pages; page++; } // Sync posts Posts posts = client.createPosts(); page = 1; totalPages = 1; List<Integer> remoteIds = new ArrayList<>(); while (page <= totalPages) { PostsContainer postsContainer = posts.blockingGetPosts(page, null, Status.ALL, false); for (Post post : postsContainer.posts) { remoteIds.add(post.id); post.blog = blog; savePost(post, syncResult); saveContent(blog, post.image, ContentProvider.createUri(Post.class, post.getId())); } totalPages = postsContainer.meta.pagination.pages; page++; } page = 1; totalPages = 1; while (page <= totalPages) { PostsContainer postsContainer = posts.blockingGetPosts(page, null, Status.ALL, true); for (Post post : postsContainer.posts) { remoteIds.add(post.id); post.blog = blog; savePost(post, syncResult); saveContent(blog, post.image, ContentProvider.createUri(Post.class, post.getId())); } totalPages = postsContainer.meta.pagination.pages; page++; } // Check for deleted posts and remove/flag List<Post> dbPosts = new Select().from(Post.class).where("blog_id = ?", blog.getId()).execute(); for (Post post : dbPosts) { if (!remoteIds.contains(post.id)) { if (post.updatedLocally) { post.remoteDeleted = true; post.save(); syncResult.stats.numUpdates++; } else { post.delete(); syncResult.stats.numDeletes++; } } } } /** * Save content (expects images) * * @param blog * @param path * @param notificationUri * @throws NoSuchAlgorithmException * @throws IOException */ private void saveContent(Blog blog, String path, Uri notificationUri) throws NoSuchAlgorithmException, IOException { // Check that the path looks like something valid if (path == null || path.trim().isEmpty()) { return; } // Make sure the path is a full URL path = ImageUtils.getUrl(blog, path); // Generate a filename String filename = ImageUtils.getFilename(getContext(), blog, path); // Make sure destination directory exists if (!ImageUtils.ensureDirectory(filename.substring(0, filename.lastIndexOf('/')))) { Log.e(TAG, "Content directory missing"); return; } // Check if the file exists if (ImageUtils.fileExists(filename)) { Log.d(TAG, "File exists skipping"); return; } // Connect URL url = new URL(path); URLConnection connection = url.openConnection(); connection.connect(); // Save the image as a temporary file as IO errors on bitmap decode never throw an error Log.d(TAG, "Saving temporary file: " + filename + ".tmp"); File file = new File(filename + ".tmp"); IOUtils.copy(connection.getInputStream(), new FileOutputStream(file)); // Decode the file Log.d(TAG, "Decoding to file: " + filename); ImageUtils.decodeScale(new FileInputStream(file), filename, 2048, 2048); file.delete(); if (notificationUri != null) { getContext().getContentResolver().notifyChange(notificationUri, null); } } /** * Save a user record if necessary * * @param user * @param syncResult */ private void saveUser(User user, SyncResult syncResult) { // Get the local record User dbUser = new Select().from(User.class) .where("blog_id = ? AND remote_id = ?", user.blog.getId(), user.id).executeSingle(); // Check to see if record needs saving if (dbUser != null) { // Check if user was actually updated if (user.updatedAt.compareTo(dbUser.updatedAt) <= 0) { Log.d(TAG, "User is unchanged"); return; } } // Save the record user.save(); if (dbUser == null) { syncResult.stats.numInserts++; } else { syncResult.stats.numUpdates++; } } /** * Save a setting record if necessary * * @param setting * @param syncResult */ private void saveSetting(Setting setting, SyncResult syncResult) { // Get the local record Setting dbSetting = new Select().from(Setting.class) .where("blog_id = ? AND remote_id = ?", setting.blog.getId(), setting.id).executeSingle(); // Check to see if record needs saving if (dbSetting != null) { // Check if setting was actually updated if (setting.updatedAt.compareTo(dbSetting.updatedAt) <= 0) { Log.d(TAG, "Setting is unchanged"); return; } } // Save the record setting.save(); if (dbSetting == null) { syncResult.stats.numInserts++; } else { syncResult.stats.numUpdates++; } } /** * Save a tag record if necessary * * @param tag * @param syncResult */ private void saveTag(Tag tag, SyncResult syncResult) { // Get the local record Tag dbTag = new Select().from(Tag.class).where("blog_id = ? AND remote_id = ?", tag.blog.getId(), tag.id) .executeSingle(); // Check to see if record needs saving if (dbTag != null) { // Check if tag was actually updated if (tag.updatedAt.compareTo(dbTag.updatedAt) <= 0) { Log.d(TAG, "Tag is unchanged"); return; } } // Save the record tag.save(); if (dbTag == null) { syncResult.stats.numInserts++; } else { syncResult.stats.numUpdates++; } } /** * Save a post record if necessary * * @param post * @param syncResult */ private void savePost(Post post, SyncResult syncResult) { // Get the local record Post dbPost = new Select().from(Post.class) .where("blog_id = ? AND remote_id = ?", post.blog.getId(), post.id).executeSingle(); // Check to see if record needs saving if (dbPost != null) { // If local updates exist, skip but check for conflicts if (dbPost.updatedLocally) { Log.d(TAG, "Won't update locally changed records"); if (dbPost.updatedAt != post.updatedAt) { Log.d(TAG, "Record is conflicted, changed locally and remotely"); dbPost.remoteConflicted = true; dbPost.save(); } return; } // Check if post was actually updated if (post.updatedAt.compareTo(dbPost.updatedAt) <= 0) { Log.d(TAG, "Post is unchanged"); return; } } // Save the record ActiveAndroid.beginTransaction(); try { // Save the post post.save(); if (dbPost == null) { syncResult.stats.numInserts++; } else { syncResult.stats.numUpdates++; } // Delete any existing post/tag associations List<PostTag> dbPostTags = new Select().from(PostTag.class).where("post_id = ?", post.getId()) .execute(); for (PostTag dbPostTag : dbPostTags) { dbPostTag.delete(); syncResult.stats.numDeletes++; } // Link current tags to the post for (Tag tag : post.tags) { // Get the tag record from the database as the object created from JSON doesn't // save correctly unless saved first itself Tag dbTag = new Select().from(Tag.class).where("remote_id = ?", tag.id).executeSingle(); if (dbTag != null) { PostTag postTag = new PostTag(); postTag.post = post; postTag.tag = dbTag; postTag.save(); syncResult.stats.numInserts++; } } ActiveAndroid.setTransactionSuccessful(); } finally { ActiveAndroid.endTransaction(); } } }