diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 11afebe6..2b827acf 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,7 +8,7 @@ # How to contribute & build *FastHub* -If you have a question in mind, feel free to come our public [Slack](http://rebrand.ly/fasthub-slack) channel. +If you have a question in mind, feel free to come our public [Slack](http://rebrand.ly/fasthub) channel. ### Optional diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 661d44b3..15ff17a8 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -4,8 +4,7 @@ - Make sure that you are always on the latest version. - Search issue before submitting a new one. - Public Slack channel: https://rebrand.ly/fasthub-slack - Discord: https://discord.gg/V6afZWf + Public Slack channel: https://rebrand.ly/fasthub #### How to submit Issue/Feature Request to *FastHub* - Make sure the included template is filled ( using FastHub will fill them up automatically ). diff --git a/.github/check_translations.cs b/.github/check_translations.cs deleted file mode 100644 index 6a0130ef..00000000 --- a/.github/check_translations.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Management.Automation; -using System.Xml; - -void Main(string[] args) -{ - string RootDir = System.Reflection.Assembly.GetExecutingAssembly().Location; - string ResDir = Path.Combine(RootDir, @"app\src\main\res"); - - int TotalFiles = 0, - TotalIssues = 0; - - foreach (string dir in Directory.GetDirectories(ResDir, "values-*").Where(d => File.Exists(Path.Combine(d, @"strings.xml")))) { - Console.WriteLine(@"Checking ""{0}""...", dir); - - XmlDocument xmlFile = new XmlDocument(); - xmlFile.Load(Path.Combine(dir, @"strings.xml")); - XmlElement xRoot = xmlFile.DocumentElement; - - bool wasAdded = false; - - foreach (XmlNode xmlNode in xRoot) { - if (xmlNode.Attributes == null) { - continue; - } - if (xmlNode.Attributes.Count > 0) { - foreach (XmlNode attr in xmlNode.Attributes) { - if (attr == null) { - continue; - } - if (attr.Name == "translatable") { - TotalIssues++; - PowerShell ps = PowerShell.Create(); - ps.AddCommand("Add-AppveyorMessage"); - ps.AddArgument(String.Format(@"Found **{0}=""{1}""** {4} in **""{2}""**. {4}File: **""{3}""**", attr.Name, attr.Value, xmlNode.OuterXml, dir, Environment.NewLine)); - ps.Invoke(); - Console.WriteLine(@" {0}=""{1}"" in {2}", attr.Name, attr.Value, xmlNode.OuterXml); - if (wasAdded) { - continue; - } - TotalFiles++; - wasAdded = true; - } - } - } - } - } - - Console.WriteLine("Found {0} issue(s) in {1} file(s).", TotalIssues, TotalFiles); - if (TotalIssues != 0) { - PowerShell ps = PowerShell.Create(); - ps.AddCommand("Add-AppveyorMessage"); - ps.AddArgument(@"Please, remove the string(s) and commit again."); - ps.Invoke(); - Environment.Exit(101); - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index bc16cd71..3133b75a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,14 +3,10 @@ /local.properties .DS_Store /build -/captures -.externalNativeBuild /gradle.properties /.idea/ /app/google-services.json -/app/db/ /app/build/ /app/src/main/res/values/secrets.xml /app/fastaccess-key -fast-for-github-firebase-crashreporting-7lngx-6b5be91d98.json -/fastScroller/build/ +/jobdispatcher/build/ diff --git a/LICENSE b/LICENSE index 9cecc1d4..b8c2b32b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,9 @@ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 + +P.S: All Fasthub Pro features shouldn't be distributed or reimplemented in any fork or librated version. These features should only be available from FastHub original project & therefore it's prohibited for anyone to provide them for free or sell them for themselves. + Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. diff --git a/README.md b/README.md index 0465ccc8..c93c5569 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Build Status](https://travis-ci.org/k0shk0sh/FastHub.svg?branch=master)](https://travis-ci.org/k0shk0sh/FastHub) [![Build status](https://ci.appveyor.com/api/projects/status/2yhxx7hu6hju24bk?svg=true)](https://ci.appveyor.com/project/k0shk0sh/fasthub) -[![Releases](https://img.shields.io/github/release/k0shk0sh/FastHub.svg)](https://github.com/k0shk0sh/FastHub/releases/latest) [![Discord](https://img.shields.io/badge/chat-discord-7289DA.svg)](https://discord.gg/V6afZWf) +[![Build Status](https://travis-ci.org/k0shk0sh/FastHub.svg?branch=master)](https://travis-ci.org/k0shk0sh/FastHub) +[![Releases](https://img.shields.io/github/release/k0shk0sh/FastHub.svg)](https://github.com/k0shk0sh/FastHub/releases/latest) [![Slack](https://img.shields.io/badge/slack-join-e01563.svg)](http://rebrand.ly/fasthub) ![Logo](/.github/assets/feature_graphic.png?raw=true "Logo") @@ -18,10 +18,6 @@ Yet another **open-source** GitHub client app but unlike any other app, FastHub alt="Direct apk download" height="80">](https://github.com/k0shk0sh/FastHub/releases/latest) -#### Snapshots / Test builds - -We have configured snapshots of FastHub, which can be downloaded from [AppVeyor CI](https://ci.appveyor.com/project/k0shk0sh/fasthub/build/artifacts). - # Features - **App** - Three login types (Basic Auth), (Access Token) or via (OAuth) @@ -37,6 +33,7 @@ We have configured snapshots of FastHub, which can be downloaded from [AppVeyor - Wiki - **Repositories** - Browse & Read Wiki + - Edit, Create & Delete files (commit) - Search Repos - Browse and search Repos - See your public, private and forked Repos diff --git a/app/build.gradle b/app/build.gradle index a1be537c..60dc4579 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,7 +3,7 @@ apply plugin: 'com.apollographql.android' apply plugin: 'kotlin-android' apply plugin: 'com.novoda.build-properties' apply plugin: 'jacoco-android' -if (isProduction) apply plugin: 'io.fabric' +apply plugin: 'io.fabric' buildProperties { notThere { @@ -29,8 +29,8 @@ android { applicationId "com.fastaccess.github" minSdkVersion 21 targetSdkVersion 26 - versionCode 403 - versionName "4.0.3" + versionCode 420 + versionName "4.2.0" buildConfigString "GITHUB_CLIENT_ID", (buildProperties.secrets['github_client_id'] | buildProperties.notThere['github_client_id']).string buildConfigString "GITHUB_SECRET", (buildProperties.secrets['github_secret'] | buildProperties.notThere['github_secret']).string buildConfigString "IMGUR_CLIENT_ID", (buildProperties.secrets['imgur_client_id'] | buildProperties.notThere['imgur_client_id']).string @@ -59,6 +59,7 @@ android { } applicationIdSuffix ".debug" versionNameSuffix "-debug" + ext.alwaysUpdateBuildId = false } } @@ -95,6 +96,7 @@ android { dexOptions { jumboMode true + javaMaxHeapSize "4g" } testOptions { @@ -110,7 +112,7 @@ repositories { maven { url "https://clojars.org/repo/" } maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } maven { url "https://jitpack.io" } - if (isProduction) maven { url 'https://maven.fabric.io/public' } + maven { url 'https://maven.fabric.io/public' } } dependencies { @@ -155,8 +157,7 @@ dependencies { implementation "com.google.android.gms:play-services-base:${gms}" implementation('com.github.b3er.rxfirebase:firebase-database-kotlin:11.2.0') { transitive = false } implementation('com.github.b3er.rxfirebase:firebase-database:11.2.0') { transitive = false } - implementation 'com.firebase:firebase-jobdispatcher:0.7.0' - if (isProduction) implementation('com.crashlytics.sdk.android:crashlytics:2.6.8@aar') { transitive = true } + implementation('com.crashlytics.sdk.android:crashlytics:2.6.8@aar') { transitive = true } implementation "com.github.miguelbcr:RxBillingService:0.0.3" implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" implementation 'org.jsoup:jsoup:1.10.2' @@ -165,6 +166,8 @@ dependencies { implementation 'com.apollographql.apollo:apollo-rx2-support:0.4.0' implementation 'com.jaredrummler:android-device-names:1.1.4' implementation 'net.yslibrary.keyboardvisibilityevent:keyboardvisibilityevent:2.1.0' + implementation 'com.airbnb.android:lottie:2.2.0' + implementation project(path: ':jobdispatcher') compileOnly "org.projectlombok:lombok:${lombokVersion}" kapt "org.projectlombok:lombok:${lombokVersion}" kapt "com.evernote:android-state-processor:${state_version}" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 73be4e64..af293c90 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -71,6 +71,12 @@ -keeppackagenames org.jsoup.nodes -keep class com.github.b3er.** { *; } -keep class com.memoizrlabs.** { *; } +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.AppGlideModule +-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { + **[] $VALUES; + public *; +} -dontwarn com.github.b3er.** -dontwarn com.memoizrlabs.** @@ -124,4 +130,5 @@ -dontwarn sun.misc.Unsafe -dontwarn com.octo.android.robospice.retrofit.RetrofitJackson** -dontwarn retrofit.appengine.UrlFetchClient --dontwarn icepick.** \ No newline at end of file +-dontwarn icepick.** +-dontwarn com.fastaccess.ui.modules.repos.** \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d222417b..a8dc0933 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> @@ -233,6 +234,31 @@ android:screenOrientation="portrait" android:windowSoftInputMode="stateAlwaysHidden"/> + + + + + + + + + + + getDataStore() { if (dataStore == null) { EntityModel model = Models.DEFAULT; - DatabaseSource source = new DatabaseSource(this, model, "FastHub-DB", 11); + DatabaseSource source = new DatabaseSource(this, model, "FastHub-DB", 13); Configuration configuration = source.getConfiguration(); if (BuildConfig.DEBUG) { source.setTableCreationMode(TableCreationMode.CREATE_NOT_EXISTS); diff --git a/app/src/main/java/com/fastaccess/data/dao/CommentRequestModel.java b/app/src/main/java/com/fastaccess/data/dao/CommentRequestModel.java index caf42eed..d066b22f 100644 --- a/app/src/main/java/com/fastaccess/data/dao/CommentRequestModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/CommentRequestModel.java @@ -24,13 +24,15 @@ import lombok.Setter; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; + CommentRequestModel that = (CommentRequestModel) o; - return position == that.position && (path != null ? path.equals(that.path) : that.path == null); + return (path != null ? path.equals(that.path) : that.path == null) && + (position != null ? position.equals(that.position) : that.position == null); } @Override public int hashCode() { int result = path != null ? path.hashCode() : 0; - result = 31 * result + position; + result = 31 * result + (position != null ? position.hashCode() : 0); return result; } diff --git a/app/src/main/java/com/fastaccess/data/dao/CommitLinesModel.java b/app/src/main/java/com/fastaccess/data/dao/CommitLinesModel.java index 9940b56c..f1c59295 100644 --- a/app/src/main/java/com/fastaccess/data/dao/CommitLinesModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/CommitLinesModel.java @@ -35,6 +35,7 @@ import static com.fastaccess.ui.widgets.DiffLineSpan.HUNK_TITLE; public int rightLineNo; public boolean noNewLine; public int position; + private boolean hasCommentedOn; @NonNull public static List getLines(@Nullable String text) { ArrayList models = new ArrayList<>(); @@ -82,7 +83,7 @@ import static com.fastaccess.ui.widgets.DiffLineSpan.HUNK_TITLE; token = token.replace("\\ No newline at end of file", ""); } models.add(new CommitLinesModel(token, color, token.startsWith("@@") || !addLeft ? -1 : leftLineNo, - token.startsWith("@@") || !addRight ? -1 : rightLineNo, index != -1, position)); + token.startsWith("@@") || !addRight ? -1 : rightLineNo, index != -1, position, false)); } } } diff --git a/app/src/main/java/com/fastaccess/data/dao/CommitRequestModel.java b/app/src/main/java/com/fastaccess/data/dao/CommitRequestModel.java new file mode 100644 index 00000000..adc115f7 --- /dev/null +++ b/app/src/main/java/com/fastaccess/data/dao/CommitRequestModel.java @@ -0,0 +1,42 @@ +package com.fastaccess.data.dao; + +/** + * Created by kosh on 31/08/2017. + */ + +public class CommitRequestModel { + + private String message; + private String content; + private String sha; + + public CommitRequestModel(String message, String content, String sha) { + this.message = message; + this.content = content; + this.sha = sha; + } + + public String getSha() { + return sha; + } + + public void setSha(String sha) { + this.sha = sha; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/app/src/main/java/com/fastaccess/data/dao/EditRepoFileModel.kt b/app/src/main/java/com/fastaccess/data/dao/EditRepoFileModel.kt new file mode 100644 index 00000000..5e837e85 --- /dev/null +++ b/app/src/main/java/com/fastaccess/data/dao/EditRepoFileModel.kt @@ -0,0 +1,51 @@ +package com.fastaccess.data.dao + +import android.os.Parcel +import android.os.Parcelable + +/** + * Created by Hashemsergani on 01/09/2017. + */ +data class EditRepoFileModel(val login: String, + val repoId: String, + val path: String?, + val ref: String, + val sha: String?, + val contentUrl: String?, + val fileName: String?, + val isEdit: Boolean) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readString(), + parcel.readString(), + parcel.readString(), + parcel.readString(), + parcel.readString(), + parcel.readString(), + parcel.readString(), + parcel.readByte() != 0.toByte()) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(login) + parcel.writeString(repoId) + parcel.writeString(path) + parcel.writeString(ref) + parcel.writeString(sha) + parcel.writeString(contentUrl) + parcel.writeString(fileName) + parcel.writeByte(if (isEdit) 1 else 0) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): EditRepoFileModel { + return EditRepoFileModel(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/data/dao/FragmentPagerAdapterModel.java b/app/src/main/java/com/fastaccess/data/dao/FragmentPagerAdapterModel.java index ee63cad4..0e3d4c02 100644 --- a/app/src/main/java/com/fastaccess/data/dao/FragmentPagerAdapterModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/FragmentPagerAdapterModel.java @@ -44,6 +44,8 @@ import com.fastaccess.ui.modules.repos.extras.branches.BranchesFragment; import com.fastaccess.ui.modules.repos.issues.issue.RepoClosedIssuesFragment; import com.fastaccess.ui.modules.repos.issues.issue.RepoOpenedIssuesFragment; import com.fastaccess.ui.modules.repos.issues.issue.details.timeline.IssueTimelineFragment; +import com.fastaccess.ui.modules.repos.projects.list.RepoProjectFragment; +import com.fastaccess.ui.modules.repos.projects.columns.ProjectColumnFragment; import com.fastaccess.ui.modules.repos.pull_requests.pull_request.RepoPullRequestFragment; import com.fastaccess.ui.modules.repos.pull_requests.pull_request.details.commits.PullRequestCommitsFragment; import com.fastaccess.ui.modules.repos.pull_requests.pull_request.details.files.PullRequestFilesFragment; @@ -152,7 +154,8 @@ import lombok.Setter; } @NonNull public static List buildForGist(@NonNull Context context, @NonNull Gist gistsModel) { - return Stream.of(new FragmentPagerAdapterModel(context.getString(R.string.files), GistFilesListFragment.newInstance(gistsModel.getFilesAsList(), false)), + return Stream.of(new FragmentPagerAdapterModel(context.getString(R.string.files), GistFilesListFragment.newInstance(gistsModel + .getFilesAsList(), false)), new FragmentPagerAdapterModel(context.getString(R.string.comments), GistCommentsFragment.newInstance(gistsModel.getGistId()))) .collect(Collectors.toList()); } @@ -218,6 +221,7 @@ import lombok.Setter; new FragmentPagerAdapterModel("", ThemeFragment.Companion.newInstance(R.style.ThemeDark)), new FragmentPagerAdapterModel("", ThemeFragment.Companion.newInstance(R.style.ThemeAmlod)), new FragmentPagerAdapterModel("", ThemeFragment.Companion.newInstance(R.style.ThemeBluish))) +// new FragmentPagerAdapterModel("", ThemeFragment.Companion.newInstance(R.style.ThemeMidnight))) .collect(Collectors.toList()); } @@ -228,4 +232,20 @@ import lombok.Setter; BranchesFragment.Companion.newInstance(login, repoId, false))) .toList(); } + + @NonNull public static List buildForRepoProjects(@NonNull Context context, @NonNull String repoId, + @NonNull String login) { + return Stream.of(new FragmentPagerAdapterModel(context.getString(R.string.open), + RepoProjectFragment.Companion.newInstance(login, repoId, IssueState.open)), + new FragmentPagerAdapterModel(context.getString(R.string.closed), + RepoProjectFragment.Companion.newInstance(login, repoId, IssueState.closed))) + .toList(); + } + + @NonNull public static List buildForProjectColumns(@NonNull List models, boolean isCollaborator) { + return Stream.of(models) + .map(projectColumnModel -> new FragmentPagerAdapterModel("", ProjectColumnFragment.Companion + .newInstance(projectColumnModel, isCollaborator))) + .toList(); + } } diff --git a/app/src/main/java/com/fastaccess/data/dao/GitCommitModel.java b/app/src/main/java/com/fastaccess/data/dao/GitCommitModel.java index 415b1a63..61dd1576 100644 --- a/app/src/main/java/com/fastaccess/data/dao/GitCommitModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/GitCommitModel.java @@ -61,6 +61,11 @@ public class GitCommitModel implements Parcelable { }; @Override public String toString() { - return sha != null && sha.length() > 10 ? sha.substring(0, 10) : "N/A"; + if (message != null) { + return (sha != null && sha.length() > 7 ? sha.substring(0, 7) + " - " : "") + message.split(System.lineSeparator())[0]; + } else if (sha != null && sha.length() > 10) { + return sha.substring(0, 10); + } + return "N/A"; } } diff --git a/app/src/main/java/com/fastaccess/data/dao/GroupedNotificationModel.java b/app/src/main/java/com/fastaccess/data/dao/GroupedNotificationModel.java index 515d3cb1..d52b86c0 100644 --- a/app/src/main/java/com/fastaccess/data/dao/GroupedNotificationModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/GroupedNotificationModel.java @@ -48,6 +48,7 @@ import static com.annimon.stream.Collectors.toList; List models = new ArrayList<>(); if (items == null || items.isEmpty()) return models; Map> grouped = Stream.of(items) + .filter(value -> !value.isUnread()) .collect(Collectors.groupingBy(Notification::getRepository, LinkedHashMap::new, Collectors.mapping(o -> o, toList()))); Stream.of(grouped) diff --git a/app/src/main/java/com/fastaccess/data/dao/ProjectCardModel.java b/app/src/main/java/com/fastaccess/data/dao/ProjectCardModel.java new file mode 100644 index 00000000..4f0fba75 --- /dev/null +++ b/app/src/main/java/com/fastaccess/data/dao/ProjectCardModel.java @@ -0,0 +1,121 @@ +package com.fastaccess.data.dao; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.fastaccess.data.dao.model.User; + +import java.util.Date; + +/** + * Created by Hashemsergani on 11.09.17. + */ + +public class ProjectCardModel implements Parcelable { + private String url; + private String columnUrl; + private String contentUrl; + private int id; + private String note; + private User creator; + private Date createdAt; + private Date updatedAt; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getColumnUrl() { + return columnUrl; + } + + public void setColumnUrl(String columnUrl) { + this.columnUrl = columnUrl; + } + + public String getContentUrl() { + return contentUrl; + } + + public void setContentUrl(String contentUrl) { + this.contentUrl = contentUrl; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public User getCreator() { + return creator; + } + + public void setCreator(User creator) { + this.creator = creator; + } + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + public Date getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Date updatedAt) { + this.updatedAt = updatedAt; + } + + @Override public int describeContents() { return 0; } + + @Override public void writeToParcel(Parcel dest, int flags) { + dest.writeString(this.url); + dest.writeString(this.columnUrl); + dest.writeString(this.contentUrl); + dest.writeInt(this.id); + dest.writeString(this.note); + dest.writeParcelable(this.creator, flags); + dest.writeLong(this.createdAt != null ? this.createdAt.getTime() : -1); + dest.writeLong(this.updatedAt != null ? this.updatedAt.getTime() : -1); + } + + public ProjectCardModel() {} + + protected ProjectCardModel(Parcel in) { + this.url = in.readString(); + this.columnUrl = in.readString(); + this.contentUrl = in.readString(); + this.id = in.readInt(); + this.note = in.readString(); + this.creator = in.readParcelable(User.class.getClassLoader()); + long tmpCreatedAt = in.readLong(); + this.createdAt = tmpCreatedAt == -1 ? null : new Date(tmpCreatedAt); + long tmpUpdatedAt = in.readLong(); + this.updatedAt = tmpUpdatedAt == -1 ? null : new Date(tmpUpdatedAt); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override public ProjectCardModel createFromParcel(Parcel source) {return new ProjectCardModel(source);} + + @Override public ProjectCardModel[] newArray(int size) {return new ProjectCardModel[size];} + }; +} diff --git a/app/src/main/java/com/fastaccess/data/dao/ProjectColumnModel.java b/app/src/main/java/com/fastaccess/data/dao/ProjectColumnModel.java new file mode 100644 index 00000000..e2492024 --- /dev/null +++ b/app/src/main/java/com/fastaccess/data/dao/ProjectColumnModel.java @@ -0,0 +1,109 @@ +package com.fastaccess.data.dao; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Date; + +/** + * Created by Hashemsergani on 11.09.17. + */ + +public class ProjectColumnModel implements Parcelable { + + private long id; + private String name; + private String url; + private String projectUrl; + private String cardsUrl; + private Date createdAt; + private Date updatedAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getProjectUrl() { + return projectUrl; + } + + public void setProjectUrl(String projectUrl) { + this.projectUrl = projectUrl; + } + + public String getCardsUrl() { + return cardsUrl; + } + + public void setCardsUrl(String cardsUrl) { + this.cardsUrl = cardsUrl; + } + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + public Date getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Date updatedAt) { + this.updatedAt = updatedAt; + } + + @Override public int describeContents() { return 0; } + + @Override public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(this.id); + dest.writeString(this.name); + dest.writeString(this.url); + dest.writeString(this.projectUrl); + dest.writeString(this.cardsUrl); + dest.writeLong(this.createdAt != null ? this.createdAt.getTime() : -1); + dest.writeLong(this.updatedAt != null ? this.updatedAt.getTime() : -1); + } + + public ProjectColumnModel() {} + + protected ProjectColumnModel(Parcel in) { + this.id = in.readLong(); + this.name = in.readString(); + this.url = in.readString(); + this.projectUrl = in.readString(); + this.cardsUrl = in.readString(); + long tmpCreatedAt = in.readLong(); + this.createdAt = tmpCreatedAt == -1 ? null : new Date(tmpCreatedAt); + long tmpUpdatedAt = in.readLong(); + this.updatedAt = tmpUpdatedAt == -1 ? null : new Date(tmpUpdatedAt); + } + + public static final Creator CREATOR = new Creator() { + @Override public ProjectColumnModel createFromParcel(Parcel source) {return new ProjectColumnModel(source);} + + @Override public ProjectColumnModel[] newArray(int size) {return new ProjectColumnModel[size];} + }; +} diff --git a/app/src/main/java/com/fastaccess/data/dao/ProjectsModel.java b/app/src/main/java/com/fastaccess/data/dao/ProjectsModel.java new file mode 100644 index 00000000..92244fb6 --- /dev/null +++ b/app/src/main/java/com/fastaccess/data/dao/ProjectsModel.java @@ -0,0 +1,165 @@ +package com.fastaccess.data.dao; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.fastaccess.data.dao.model.User; + +import java.util.Date; + +/** + * Created by kosh on 09/09/2017. + */ + +public class ProjectsModel implements Parcelable { + private String ownerUrl; + private String url; + private String htmlUrl; + private String columnsUrl; + private long id; + private String name; + private String body; + private int number; + private String state; + private User creator; + private Date createdAt; + private Date updatedAt; + + public String getOwnerUrl() { + return ownerUrl; + } + + public void setOwnerUrl(String ownerUrl) { + this.ownerUrl = ownerUrl; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getHtmlUrl() { + return htmlUrl; + } + + public void setHtmlUrl(String htmlUrl) { + this.htmlUrl = htmlUrl; + } + + public String getColumnsUrl() { + return columnsUrl; + } + + public void setColumnsUrl(String columnsUrl) { + this.columnsUrl = columnsUrl; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public User getCreator() { + return creator; + } + + public void setCreator(User creator) { + this.creator = creator; + } + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + public Date getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Date updatedAt) { + this.updatedAt = updatedAt; + } + + @Override public int describeContents() { return 0; } + + @Override public void writeToParcel(Parcel dest, int flags) { + dest.writeString(this.ownerUrl); + dest.writeString(this.url); + dest.writeString(this.htmlUrl); + dest.writeString(this.columnsUrl); + dest.writeLong(this.id); + dest.writeString(this.name); + dest.writeString(this.body); + dest.writeInt(this.number); + dest.writeString(this.state); + dest.writeParcelable(this.creator, flags); + dest.writeLong(this.createdAt != null ? this.createdAt.getTime() : -1); + dest.writeLong(this.updatedAt != null ? this.updatedAt.getTime() : -1); + } + + public ProjectsModel() {} + + protected ProjectsModel(Parcel in) { + this.ownerUrl = in.readString(); + this.url = in.readString(); + this.htmlUrl = in.readString(); + this.columnsUrl = in.readString(); + this.id = in.readLong(); + this.name = in.readString(); + this.body = in.readString(); + this.number = in.readInt(); + this.state = in.readString(); + this.creator = in.readParcelable(User.class.getClassLoader()); + long tmpCreatedAt = in.readLong(); + this.createdAt = tmpCreatedAt == -1 ? null : new Date(tmpCreatedAt); + long tmpUpdatedAt = in.readLong(); + this.updatedAt = tmpUpdatedAt == -1 ? null : new Date(tmpUpdatedAt); + } + + public static final Creator CREATOR = new Creator() { + @Override public ProjectsModel createFromParcel(Parcel source) {return new ProjectsModel(source);} + + @Override public ProjectsModel[] newArray(int size) {return new ProjectsModel[size];} + }; +} diff --git a/app/src/main/java/com/fastaccess/data/dao/ReactionsModel.java b/app/src/main/java/com/fastaccess/data/dao/ReactionsModel.java index 26793c22..ee84a907 100644 --- a/app/src/main/java/com/fastaccess/data/dao/ReactionsModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/ReactionsModel.java @@ -13,7 +13,7 @@ import java.util.List; import lombok.Getter; import lombok.NonNull; import lombok.Setter; -import pr.PullRequestTimelineQuery; +import github.PullRequestTimelineQuery; /** * Created by Kosh on 28 Mar 2017, 9:15 PM diff --git a/app/src/main/java/com/fastaccess/data/dao/ReviewCommentModel.java b/app/src/main/java/com/fastaccess/data/dao/ReviewCommentModel.java index fbfb288e..eaf66c5d 100644 --- a/app/src/main/java/com/fastaccess/data/dao/ReviewCommentModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/ReviewCommentModel.java @@ -30,6 +30,7 @@ import java.util.Date; private String htmlUrl; private String pullRequestUrl; private ReactionsModel reactions; + private String authorAssociation; public ReviewCommentModel() {} @@ -161,6 +162,14 @@ import java.util.Date; this.reactions = reactions; } + public String getAuthorAssociation() { + return authorAssociation; + } + + public void setAuthorAssociation(String authorAssociation) { + this.authorAssociation = authorAssociation; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; @@ -174,6 +183,14 @@ import java.util.Date; return (int) (id ^ (id >>> 32)); } + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { @@ -194,6 +211,7 @@ import java.util.Date; dest.writeString(this.htmlUrl); dest.writeString(this.pullRequestUrl); dest.writeParcelable(this.reactions, flags); + dest.writeString(this.authorAssociation); } protected ReviewCommentModel(Parcel in) { @@ -216,6 +234,7 @@ import java.util.Date; this.htmlUrl = in.readString(); this.pullRequestUrl = in.readString(); this.reactions = in.readParcelable(ReactionsModel.class.getClassLoader()); + this.authorAssociation = in.readString(); } public static final Creator CREATOR = new Creator() { @@ -223,12 +242,4 @@ import java.util.Date; @Override public ReviewCommentModel[] newArray(int size) {return new ReviewCommentModel[size];} }; - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } } diff --git a/app/src/main/java/com/fastaccess/data/dao/TabsCountStateModel.java b/app/src/main/java/com/fastaccess/data/dao/TabsCountStateModel.java index f607bf30..dcb45ede 100644 --- a/app/src/main/java/com/fastaccess/data/dao/TabsCountStateModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/TabsCountStateModel.java @@ -6,18 +6,39 @@ import android.support.annotation.DrawableRes; import java.io.Serializable; -import lombok.Getter; -import lombok.Setter; - /** * Created by Kosh on 27 Apr 2017, 6:10 PM */ -@Getter @Setter public class TabsCountStateModel implements Parcelable, Serializable { +public class TabsCountStateModel implements Parcelable, Serializable { private int count; private int tabIndex; @DrawableRes private int drawableId; + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public int getTabIndex() { + return tabIndex; + } + + public void setTabIndex(int tabIndex) { + this.tabIndex = tabIndex; + } + + public int getDrawableId() { + return drawableId; + } + + public void setDrawableId(int drawableId) { + this.drawableId = drawableId; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; diff --git a/app/src/main/java/com/fastaccess/data/dao/model/AbstractComment.java b/app/src/main/java/com/fastaccess/data/dao/model/AbstractComment.java index eb00a991..ffcb0991 100644 --- a/app/src/main/java/com/fastaccess/data/dao/model/AbstractComment.java +++ b/app/src/main/java/com/fastaccess/data/dao/model/AbstractComment.java @@ -54,6 +54,7 @@ import static com.fastaccess.data.dao.model.Comment.UPDATED_AT; String issueId; String pullRequestId; @Convert(ReactionsConverter.class) ReactionsModel reactions; + String authorAssociation; public static Disposable saveForGist(@NonNull List models, @NonNull String gistId) { return RxHelper.getSingle(Single.fromPublisher(s -> { @@ -188,6 +189,7 @@ import static com.fastaccess.data.dao.model.Comment.UPDATED_AT; dest.writeString(this.issueId); dest.writeString(this.pullRequestId); dest.writeParcelable(this.reactions, flags); + dest.writeString(this.authorAssociation); } protected AbstractComment(Parcel in) { @@ -211,6 +213,7 @@ import static com.fastaccess.data.dao.model.Comment.UPDATED_AT; this.issueId = in.readString(); this.pullRequestId = in.readString(); this.reactions = in.readParcelable(ReactionsModel.class.getClassLoader()); + this.authorAssociation = in.readString(); } public static final Creator CREATOR = new Creator() { diff --git a/app/src/main/java/com/fastaccess/data/dao/model/AbstractNotification.java b/app/src/main/java/com/fastaccess/data/dao/model/AbstractNotification.java index 61657946..573f44dd 100644 --- a/app/src/main/java/com/fastaccess/data/dao/model/AbstractNotification.java +++ b/app/src/main/java/com/fastaccess/data/dao/model/AbstractNotification.java @@ -143,6 +143,10 @@ import lombok.NoArgsConstructor; .value() > 0; } + public static void deleteAll() { + App.getInstance().getDataStore().toBlocking().delete(Notification.class).get().value(); + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; diff --git a/app/src/main/java/com/fastaccess/data/dao/model/AbstractRepo.java b/app/src/main/java/com/fastaccess/data/dao/model/AbstractRepo.java index 4413be49..8a8c6306 100644 --- a/app/src/main/java/com/fastaccess/data/dao/model/AbstractRepo.java +++ b/app/src/main/java/com/fastaccess/data/dao/model/AbstractRepo.java @@ -120,6 +120,7 @@ import static com.fastaccess.data.dao.model.Repo.UPDATED_AT; int networkCount; String starredUser; String reposOwner; + @Nullable boolean hasProjects; public Disposable save(Repo entity) { return Single.create(e -> { @@ -147,30 +148,32 @@ import static com.fastaccess.data.dao.model.Repo.UPDATED_AT; public static Disposable saveStarred(@NonNull List models, @NonNull String starredUser) { return RxHelper.getSingle(Single.fromPublisher(s -> { - Login login = Login.getUser(); - if (login != null) { - BlockingEntityStore dataSource = App.getInstance().getDataStore().toBlocking(); - if (login.getLogin().equalsIgnoreCase(starredUser)) { - dataSource.delete(Repo.class) - .where(STARRED_USER.eq(starredUser)) - .get() - .value(); - if (!models.isEmpty()) { - for (Repo repo : models) { - dataSource.delete(Repo.class).where(Repo.ID.eq(repo.getId())).get().value(); - repo.setStarredUser(starredUser); - dataSource.insert(repo); + try { + Login login = Login.getUser(); + if (login != null) { + BlockingEntityStore dataSource = App.getInstance().getDataStore().toBlocking(); + if (login.getLogin().equalsIgnoreCase(starredUser)) { + dataSource.delete(Repo.class) + .where(STARRED_USER.eq(starredUser)) + .get() + .value(); + if (!models.isEmpty()) { + for (Repo repo : models) { + dataSource.delete(Repo.class).where(Repo.ID.eq(repo.getId())).get().value(); + repo.setStarredUser(starredUser); + dataSource.insert(repo); + } } + } else { + dataSource.delete(Repo.class) + .where(STARRED_USER.notEqual(login.getLogin()) + .or(STATUSES_URL.isNull())) + .get() + .value(); } - } else { - dataSource.delete(Repo.class) - .where(STARRED_USER.notEqual(login.getLogin()) - .or(STATUSES_URL.isNull())) - .get() - .value(); } - } - s.onNext(""); + s.onNext(""); + } catch (Exception ignored) {} s.onComplete(); })).subscribe(o -> {/*donothing*/}, Throwable::printStackTrace); } @@ -320,6 +323,7 @@ import static com.fastaccess.data.dao.model.Repo.UPDATED_AT; dest.writeInt(this.networkCount); dest.writeString(this.starredUser); dest.writeString(this.reposOwner); + dest.writeByte(this.hasProjects ? (byte) 1 : (byte) 0); } protected AbstractRepo(Parcel in) { @@ -404,6 +408,7 @@ import static com.fastaccess.data.dao.model.Repo.UPDATED_AT; this.networkCount = in.readInt(); this.starredUser = in.readString(); this.reposOwner = in.readString(); + this.hasProjects = in.readByte() != 0; } public static final Creator CREATOR = new Creator() { diff --git a/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestReviewModel.java b/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestReviewModel.java index 63889376..f260ea42 100644 --- a/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestReviewModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestReviewModel.java @@ -10,8 +10,8 @@ import com.fastaccess.helper.ParseDateFormat; import java.util.ArrayList; import java.util.List; -import pr.PullRequestTimelineQuery; -import pr.type.PullRequestReviewState; +import github.PullRequestTimelineQuery; +import github.type.PullRequestReviewState; /** * Created by kosh on 20/08/2017. diff --git a/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestTimelineModel.java b/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestTimelineModel.java index fcddb8f9..0cd8bd06 100644 --- a/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestTimelineModel.java +++ b/app/src/main/java/com/fastaccess/data/dao/timeline/PullRequestTimelineModel.java @@ -7,7 +7,7 @@ import java.util.List; import lombok.Getter; import lombok.Setter; -import pr.PullRequestTimelineQuery; +import github.PullRequestTimelineQuery; /** * Created by kosh on 02/08/2017. diff --git a/app/src/main/java/com/fastaccess/data/dao/types/FilesType.java b/app/src/main/java/com/fastaccess/data/dao/types/FilesType.java index 89768701..b36dd012 100644 --- a/app/src/main/java/com/fastaccess/data/dao/types/FilesType.java +++ b/app/src/main/java/com/fastaccess/data/dao/types/FilesType.java @@ -12,7 +12,8 @@ public enum FilesType { file(R.drawable.ic_file_document), dir(R.drawable.ic_folder), blob(R.drawable.ic_file_document), - tree(R.drawable.ic_folder); + tree(R.drawable.ic_folder), + symlink(R.drawable.ic_file_document); int icon; diff --git a/app/src/main/java/com/fastaccess/data/service/ContentService.kt b/app/src/main/java/com/fastaccess/data/service/ContentService.kt new file mode 100644 index 00000000..ee9328ff --- /dev/null +++ b/app/src/main/java/com/fastaccess/data/service/ContentService.kt @@ -0,0 +1,26 @@ +package com.fastaccess.data.service + +import com.fastaccess.data.dao.CommitRequestModel +import com.fastaccess.data.dao.GitCommitModel +import io.reactivex.Observable +import retrofit2.http.* + +/** + * Created by kosh on 29/08/2017. + */ +interface ContentService { + + @PUT("repos/{owner}/{repoId}/contents/{path}") + fun updateCreateFile(@Path("owner") owner: String, + @Path("repoId") repoId: String, + @Path("path") path: String, + @Query("branch") branch: String, + @Body body: CommitRequestModel): Observable + + @HTTP(method = "DELETE", path = "repos/{owner}/{repoId}/contents/{path}", hasBody = true) + fun deleteFile(@Path("owner") owner: String, + @Path("repoId") repoId: String, + @Path("path") path: String, + @Query("branch") branch: String, + @Body body: CommitRequestModel): Observable +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/data/service/IssueService.java b/app/src/main/java/com/fastaccess/data/service/IssueService.java index 6a8835db..5c78fc79 100644 --- a/app/src/main/java/com/fastaccess/data/service/IssueService.java +++ b/app/src/main/java/com/fastaccess/data/service/IssueService.java @@ -51,11 +51,11 @@ public interface IssueService { Observable> getTimeline(@Path("owner") String owner, @Path("repo") String repo, @Path("issue_number") int issue_number); - @GET("repos/{owner}/{repo}/issues/{issue_number}/timeline") + @GET("repos/{owner}/{repo}/issues/{issue_number}/timeline?per_page=100") @Headers("Accept: application/vnd.github.mockingbird-preview,application/vnd.github.VERSION.full+json," + " application/vnd.github.squirrel-girl-preview") Observable> getTimeline(@Path("owner") String owner, @Path("repo") String repo, - @Path("issue_number") int issue_number, @Query("page") int page); + @Path("issue_number") int issue_number, @Query("page") int page); @POST("repos/{owner}/{repo}/issues") Observable createIssue(@Path("owner") String owner, @Path("repo") String repo, diff --git a/app/src/main/java/com/fastaccess/data/service/ProjectsService.kt b/app/src/main/java/com/fastaccess/data/service/ProjectsService.kt new file mode 100644 index 00000000..53231ea5 --- /dev/null +++ b/app/src/main/java/com/fastaccess/data/service/ProjectsService.kt @@ -0,0 +1,36 @@ +package com.fastaccess.data.service + +import com.fastaccess.data.dao.Pageable +import com.fastaccess.data.dao.ProjectCardModel +import com.fastaccess.data.dao.ProjectColumnModel +import com.fastaccess.data.dao.ProjectsModel +import io.reactivex.Observable +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Created by kosh on 09/09/2017. + */ + +interface ProjectsService { + + @GET("repos/{owner}/{repo}/projects") + @Headers("Accept: application/vnd.github.inertia-preview+json") + fun getRepoProjects(@Path("owner") owner: String, @Path("repo") repo: String, + @Query("state") state: String?, @Query("page") page: Int): Observable> + + @GET("orgs/{org}/projects") + @Headers("Accept: application/vnd.github.inertia-preview+json") + fun getOrgsProjects(@Path("org") org: String, + @Query("page") page: Int): Observable> + + @GET("projects/{projectId}/columns?per_page=100") + @Headers("Accept: application/vnd.github.inertia-preview+json") + fun getProjectColumns(@Path("projectId") projectId: Long): Observable> + + @GET("projects/columns/{columnId}/cards") + @Headers("Accept: application/vnd.github.inertia-preview+json") + fun getProjectCards(@Path("columnId") columnId: Long, @Query("page") page: Int): Observable> +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/data/service/RepoService.java b/app/src/main/java/com/fastaccess/data/service/RepoService.java index e1d64e80..92c0054b 100644 --- a/app/src/main/java/com/fastaccess/data/service/RepoService.java +++ b/app/src/main/java/com/fastaccess/data/service/RepoService.java @@ -81,6 +81,12 @@ public interface RepoService { Observable> getCommits(@Path("owner") String owner, @Path("repo") String repo, @NonNull @Query("sha") String branch, @Query("page") int page); + @NonNull @GET("repos/{owner}/{repo}/commits") + Observable> getCommits(@Path("owner") String owner, @Path("repo") String repo, + @NonNull @Query("sha") String branch, + @NonNull @Query("path") String path, + @Query("page") int page); + @NonNull @GET("repos/{owner}/{repo}/releases") @Headers("Accept: application/vnd.github.VERSION.full+json") Observable> getReleases(@Path("owner") String owner, @Path("repo") String repo, @Query("page") int page); diff --git a/app/src/main/java/com/fastaccess/helper/ActivityHelper.java b/app/src/main/java/com/fastaccess/helper/ActivityHelper.java index f59b1cb1..352051a3 100644 --- a/app/src/main/java/com/fastaccess/helper/ActivityHelper.java +++ b/app/src/main/java/com/fastaccess/helper/ActivityHelper.java @@ -117,24 +117,36 @@ public class ActivityHelper { } public static void startReveal(@NonNull Activity activity, Intent intent, @NonNull View sharedElement, int requestCode) { - ActivityOptionsCompat options = ActivityOptionsCompat.makeClipRevealAnimation(sharedElement, sharedElement.getWidth() / 2, - sharedElement.getHeight() / 2, - sharedElement.getWidth(), sharedElement.getHeight()); - activity.startActivityForResult(intent, requestCode, options.toBundle()); + if (!PrefGetter.isAppAnimationDisabled()) { + ActivityOptionsCompat options = ActivityOptionsCompat.makeClipRevealAnimation(sharedElement, sharedElement.getWidth() / 2, + sharedElement.getHeight() / 2, + sharedElement.getWidth(), sharedElement.getHeight()); + activity.startActivityForResult(intent, requestCode, options.toBundle()); + } else { + activity.startActivityForResult(intent, requestCode); + } } public static void startReveal(@NonNull Fragment fragment, Intent intent, @NonNull View sharedElement, int requestCode) { - ActivityOptionsCompat options = ActivityOptionsCompat.makeClipRevealAnimation(sharedElement, sharedElement.getWidth() / 2, - sharedElement.getHeight() / 2, - sharedElement.getWidth(), sharedElement.getHeight()); - fragment.startActivityForResult(intent, requestCode, options.toBundle()); + if (!PrefGetter.isAppAnimationDisabled()) { + ActivityOptionsCompat options = ActivityOptionsCompat.makeClipRevealAnimation(sharedElement, sharedElement.getWidth() / 2, + sharedElement.getHeight() / 2, + sharedElement.getWidth(), sharedElement.getHeight()); + fragment.startActivityForResult(intent, requestCode, options.toBundle()); + } else { + fragment.startActivityForResult(intent, requestCode); + } } public static void startReveal(@NonNull Activity activity, Intent intent, @NonNull View sharedElement) { - ActivityOptionsCompat options = ActivityOptionsCompat.makeClipRevealAnimation(sharedElement, sharedElement.getWidth() / 2, - sharedElement.getHeight() / 2, - sharedElement.getWidth(), sharedElement.getHeight()); - activity.startActivity(intent, options.toBundle()); + if (!PrefGetter.isAppAnimationDisabled()) { + ActivityOptionsCompat options = ActivityOptionsCompat.makeClipRevealAnimation(sharedElement, sharedElement.getWidth() / 2, + sharedElement.getHeight() / 2, + sharedElement.getWidth(), sharedElement.getHeight()); + activity.startActivity(intent, options.toBundle()); + } else { + activity.startActivity(intent); + } } @SafeVarargs public static void start(@NonNull Activity activity, @NonNull Intent intent, diff --git a/app/src/main/java/com/fastaccess/helper/AppHelper.java b/app/src/main/java/com/fastaccess/helper/AppHelper.java index 9768accd..56554ee9 100644 --- a/app/src/main/java/com/fastaccess/helper/AppHelper.java +++ b/app/src/main/java/com/fastaccess/helper/AppHelper.java @@ -34,7 +34,9 @@ public class AppHelper { public static void hideKeyboard(@NonNull View view) { InputMethodManager inputManager = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - inputManager.hideSoftInputFromWindow(view.getWindowToken(), 0); + if (inputManager != null) { + inputManager.hideSoftInputFromWindow(view.getWindowToken(), 0); + } } @Nullable public static Fragment getFragmentByTag(@NonNull FragmentManager fragmentManager, @NonNull String tag) { @@ -47,28 +49,35 @@ public class AppHelper { public static void cancelNotification(@NonNull Context context, int id) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancel(id); + if (notificationManager != null) { + notificationManager.cancel(id); + } } public static void cancelAllNotifications(@NonNull Context context) { - ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).cancelAll(); + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + notificationManager.cancelAll(); + } } public static void copyToClipboard(@NonNull Context context, @NonNull String uri) { ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); ClipData clip = ClipData.newPlainText(context.getString(R.string.app_name), uri); - clipboard.setPrimaryClip(clip); - Toasty.success(App.getInstance(), context.getString(R.string.success_copied)).show(); + if (clipboard != null) { + clipboard.setPrimaryClip(clip); + Toasty.success(App.getInstance(), context.getString(R.string.success_copied)).show(); + } } public static boolean isNightMode(@NonNull Resources resources) { @PrefGetter.ThemeType int themeType = PrefGetter.getThemeType(resources); - return themeType == PrefGetter.DARK || themeType == PrefGetter.AMLOD || themeType == PrefGetter.BLUISH; + return themeType != PrefGetter.LIGHT; } public static String getFastHubIssueTemplate(boolean enterprise) { String brand = (!isEmulator()) ? Build.BRAND : "Android Emulator"; - String model = (!isEmulator()) ? Build.MODEL : "Android Emulator"; + String model = (!isEmulator()) ? DeviceNameGetter.getInstance().getDeviceName() : "Android Emulator"; StringBuilder builder = new StringBuilder() .append("**FastHub Version: ").append(BuildConfig.VERSION_NAME).append(enterprise ? " Enterprise**" : "**").append(" \n") .append(!isInstalledFromPlaySore(App.getInstance()) ? "**APK Source: Unknown** \n" : "") @@ -86,7 +95,7 @@ public class AppHelper { builder.append("- **Model:** ").append(model).append(" \n") .append("---").append("\n\n"); if (!Locale.getDefault().getLanguage().equals(new Locale("en").getLanguage())) { - builder.append("<--") + builder.append("") .append("\n"); diff --git a/app/src/main/java/com/fastaccess/helper/PrefGetter.java b/app/src/main/java/com/fastaccess/helper/PrefGetter.java index b5fba597..f24bdf38 100644 --- a/app/src/main/java/com/fastaccess/helper/PrefGetter.java +++ b/app/src/main/java/com/fastaccess/helper/PrefGetter.java @@ -24,8 +24,8 @@ public class PrefGetter { public static final int LIGHT = 1; public static final int DARK = 2; public static final int AMLOD = 3; - public static final int MID_NIGHT_BLUE = 4; - public static final int BLUISH = 5; + public static final int BLUISH = 4; + public static final int MID_NIGHT_BLUE = 5; public static final int RED = 1; public static final int PINK = 2; @@ -91,7 +91,7 @@ public class PrefGetter { private static final String OTP_CODE = "otp_code"; private static final String ENTERPRISE_OTP_CODE = "enterprise_otp_code"; private static final String APP_LANGUAGE = "app_language"; - private static final String SENT_VIA = "sent_via"; + private static final String SENT_VIA = "fasthub_signature"; private static final String SENT_VIA_BOX = "sent_via_enabled"; private static final String PROFILE_BACKGROUND_URL = "profile_background_url"; private static final String AMLOD_THEME_ENABLED = "amlod_theme_enabled"; @@ -464,4 +464,8 @@ public class PrefGetter { public static boolean isGistDisabled() { return PrefHelper.getBoolean(DISABLE_AUTO_PLAY_GIF); } + + public static boolean isAppAnimationDisabled() { + return PrefHelper.getBoolean("app_animation"); + } } diff --git a/app/src/main/java/com/fastaccess/provider/colors/ColorsProvider.java b/app/src/main/java/com/fastaccess/provider/colors/ColorsProvider.java index a6a418ba..bccfb2ae 100644 --- a/app/src/main/java/com/fastaccess/provider/colors/ColorsProvider.java +++ b/app/src/main/java/com/fastaccess/provider/colors/ColorsProvider.java @@ -68,7 +68,7 @@ public class ColorsProvider { .filter(value -> value != null && !InputHelper.isEmpty(value.getKey())) .map(Map.Entry::getKey) .collect(Collectors.toCollection(ArrayList::new))); - lang.add(0, "All Language"); + lang.add(0, "All Languages"); lang.addAll(1, POPULAR_LANG); return lang; } diff --git a/app/src/main/java/com/fastaccess/provider/fabric/FabricProvider.java b/app/src/main/java/com/fastaccess/provider/fabric/FabricProvider.java index 6c5e7275..b69af0d7 100644 --- a/app/src/main/java/com/fastaccess/provider/fabric/FabricProvider.java +++ b/app/src/main/java/com/fastaccess/provider/fabric/FabricProvider.java @@ -3,13 +3,31 @@ package com.fastaccess.provider.fabric; import android.content.Context; import android.support.annotation.NonNull; +import com.crashlytics.android.Crashlytics; +import com.crashlytics.android.answers.Answers; +import com.crashlytics.android.answers.PurchaseEvent; +import com.crashlytics.android.core.CrashlyticsCore; +import com.fastaccess.BuildConfig; + +import io.fabric.sdk.android.Fabric; + /** * Created by kosh on 14/08/2017. */ public class FabricProvider { - public static void initFabric(@NonNull Context context) {}//DO NOTHING IN DEBUG + public static void initFabric(@NonNull Context context) { + Fabric fabric = new Fabric.Builder(context) + .kits(new Crashlytics.Builder() + .core(new CrashlyticsCore.Builder().disabled(BuildConfig.DEBUG).build()) + .build()) + .debuggable(BuildConfig.DEBUG) + .build(); + Fabric.with(fabric); + } - public static void logPurchase(@NonNull String productKey) {}//DO NOTHING IN DEBUG + public static void logPurchase(@NonNull String productKey) { + Answers.getInstance().logPurchase(new PurchaseEvent().putItemName(productKey).putSuccess(true)); + } } diff --git a/app/src/main/java/com/fastaccess/provider/fcm/PushNotificationService.java b/app/src/main/java/com/fastaccess/provider/fcm/PushNotificationService.java index 70aa6b0b..3e13f97e 100644 --- a/app/src/main/java/com/fastaccess/provider/fcm/PushNotificationService.java +++ b/app/src/main/java/com/fastaccess/provider/fcm/PushNotificationService.java @@ -19,22 +19,24 @@ public class PushNotificationService extends FirebaseMessagingService { @Override public void onMessageReceived(RemoteMessage remoteMessage) { super.onMessageReceived(remoteMessage); - String title = remoteMessage.getNotification().getTitle(); - String body = remoteMessage.getNotification().getBody(); - if (remoteMessage.getData() != null && !remoteMessage.getData().isEmpty()) { - title = title == null ? remoteMessage.getData().get("title") : title; - body = body == null ? remoteMessage.getData().get("message") : body; + if (remoteMessage != null && remoteMessage.getNotification() != null) { + String title = remoteMessage.getNotification().getTitle(); + String body = remoteMessage.getNotification().getBody(); + if (remoteMessage.getData() != null && !remoteMessage.getData().isEmpty()) { + title = title == null ? remoteMessage.getData().get("title") : title; + body = body == null ? remoteMessage.getData().get("message") : body; + } + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT); + NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pendingIntent); + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(1, notificationBuilder.build()); } - Intent intent = new Intent(this, MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT); - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title) - .setContentText(body) - .setAutoCancel(true) - .setContentIntent(pendingIntent); - NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(1, notificationBuilder.build()); } } diff --git a/app/src/main/java/com/fastaccess/provider/markdown/MarkDownProvider.java b/app/src/main/java/com/fastaccess/provider/markdown/MarkDownProvider.java index a97ccd85..6394fe67 100644 --- a/app/src/main/java/com/fastaccess/provider/markdown/MarkDownProvider.java +++ b/app/src/main/java/com/fastaccess/provider/markdown/MarkDownProvider.java @@ -10,7 +10,6 @@ import android.widget.TextView; import com.annimon.stream.IntStream; import com.fastaccess.helper.InputHelper; -import com.fastaccess.helper.Logger; import com.fastaccess.provider.markdown.extension.emoji.EmojiExtension; import com.fastaccess.provider.markdown.extension.mention.MentionExtension; import com.fastaccess.provider.timeline.HtmlHelper; @@ -41,7 +40,7 @@ public class MarkDownProvider { }; private static final String[] ARCHIVE_EXTENSIONS = { - ".zip", ".7z", ".rar", ".tar.gz", ".tgz", ".tar.Z", ".tar.bz2", ".tbz2", ".tar.lzma", ".tlz", ".apk", ".jar", ".dmg" + ".zip", ".7z", ".rar", ".tar.gz", ".tgz", ".tar.Z", ".tar.bz2", ".tbz2", ".tar.lzma", ".tlz", ".apk", ".jar", ".dmg", ".pdf" }; private MarkDownProvider() {} @@ -74,14 +73,17 @@ public class MarkDownProvider { Parser parser = Parser.builder() .extensions(extensions) .build(); - Node node = parser.parse(markdown); - String rendered = HtmlRenderer - .builder() - .extensions(extensions) - .build() - .render(node); - Logger.e(rendered); - HtmlHelper.htmlIntoTextView(textView, rendered, (width - (textView.getPaddingStart() + textView.getPaddingEnd()))); + try { + Node node = parser.parse(markdown); + String rendered = HtmlRenderer + .builder() + .extensions(extensions) + .build() + .render(node); + HtmlHelper.htmlIntoTextView(textView, rendered, (width - (textView.getPaddingStart() + textView.getPaddingEnd()))); + } catch (Exception ignored) { + HtmlHelper.htmlIntoTextView(textView, markdown, (width - (textView.getPaddingStart() + textView.getPaddingEnd()))); + } } public static void stripMdText(@NonNull TextView textView, String markdown) { @@ -252,11 +254,8 @@ public class MarkDownProvider { } public static void addPhoto(@NonNull EditText editText, @NonNull String title, @NonNull String link) { - String result = "![" + InputHelper.toString(title) + "](" + InputHelper.toString(link) + ")\n"; - String text = InputHelper.toString(editText); - text += result; - editText.setText(text); - editText.setSelection(text.length()); + String result = "![" + InputHelper.toString(title) + "](" + InputHelper.toString(link) + ")"; + insertAtCursor(editText, result); } public static void addLink(@NonNull EditText editText) { @@ -264,11 +263,8 @@ public class MarkDownProvider { } public static void addLink(@NonNull EditText editText, @NonNull String title, @NonNull String link) { - String result = "[" + InputHelper.toString(title) + "](" + InputHelper.toString(link) + ")\n"; - String text = InputHelper.toString(editText); - text += result; - editText.setText(text); - editText.setSelection(text.length()); + String result = "[" + InputHelper.toString(title) + "](" + InputHelper.toString(link) + ")"; + insertAtCursor(editText, result); } private static boolean hasNewLine(@NonNull String source, int selectionStart) { @@ -313,4 +309,13 @@ public class MarkDownProvider { return false; } + + public static void insertAtCursor(@NonNull EditText editText, @NonNull String text) { + String oriContent = editText.getText().toString(); + int index = editText.getSelectionStart() >= 0 ? editText.getSelectionStart() : 0; + StringBuilder builder = new StringBuilder(oriContent); + builder.insert(index, text); + editText.setText(builder.toString()); + editText.setSelection(index + text.length()); + } } diff --git a/app/src/main/java/com/fastaccess/provider/rest/ApolloProdivder.kt b/app/src/main/java/com/fastaccess/provider/rest/ApolloProdivder.kt new file mode 100644 index 00000000..23084334 --- /dev/null +++ b/app/src/main/java/com/fastaccess/provider/rest/ApolloProdivder.kt @@ -0,0 +1,23 @@ +package com.fastaccess.provider.rest + +import com.apollographql.apollo.ApolloClient +import com.fastaccess.BuildConfig +import com.fastaccess.helper.PrefGetter +import com.fastaccess.provider.scheme.LinkParserHelper + +/** + * Created by Hashemsergani on 12.09.17. + */ + +object ApolloProdivder { + + fun getApollo(enterprise: Boolean) = ApolloClient.builder() + .serverUrl("${if (enterprise && PrefGetter.isEnterprise()) { + "${LinkParserHelper.getEndpoint(PrefGetter.getEnterpriseUrl())}/" + } else { + BuildConfig.REST_URL + }}graphql") + .okHttpClient(RestProvider.provideOkHttpClient()) + .build() + +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/provider/rest/RestProvider.java b/app/src/main/java/com/fastaccess/provider/rest/RestProvider.java index 68633490..a21f77ad 100644 --- a/app/src/main/java/com/fastaccess/provider/rest/RestProvider.java +++ b/app/src/main/java/com/fastaccess/provider/rest/RestProvider.java @@ -13,10 +13,12 @@ import com.fastaccess.BuildConfig; import com.fastaccess.R; import com.fastaccess.data.dao.GitHubErrorResponse; import com.fastaccess.data.dao.NameParser; +import com.fastaccess.data.service.ContentService; import com.fastaccess.data.service.GistService; import com.fastaccess.data.service.IssueService; import com.fastaccess.data.service.NotificationService; import com.fastaccess.data.service.OrganizationService; +import com.fastaccess.data.service.ProjectsService; import com.fastaccess.data.service.PullRequestService; import com.fastaccess.data.service.ReactionsService; import com.fastaccess.data.service.RepoService; @@ -178,6 +180,23 @@ public class RestProvider { return provideRetrofit(enterprise).create(SearchService.class); } + @NonNull public static SlackService getSlackService() { + return new Retrofit.Builder() + .baseUrl("https://ok13pknpj4.execute-api.eu-central-1.amazonaws.com/prod/") + .addConverterFactory(new GithubResponseConverter(gson)) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build() + .create(SlackService.class); + } + + @NonNull public static ContentService getContentService(boolean enterprise) { + return provideRetrofit(enterprise).create(ContentService.class); + } + + @NonNull public static ProjectsService getProjectsService(boolean enterprise) { + return provideRetrofit(enterprise).create(ProjectsService.class); + } + @Nullable public static GitHubErrorResponse getErrorResponse(@NonNull Throwable throwable) { ResponseBody body = null; if (throwable instanceof HttpException) { @@ -191,15 +210,6 @@ public class RestProvider { return null; } - @NonNull public static SlackService getSlackService() { - return new Retrofit.Builder() - .baseUrl("https://ok13pknpj4.execute-api.eu-central-1.amazonaws.com/prod/") - .addConverterFactory(new GithubResponseConverter(gson)) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .build() - .create(SlackService.class); - } - public static void clearHttpClient() { okHttpClient = null; } diff --git a/app/src/main/java/com/fastaccess/provider/scheme/LinkParserHelper.java b/app/src/main/java/com/fastaccess/provider/scheme/LinkParserHelper.java index 2c0e6310..dedc2b6b 100644 --- a/app/src/main/java/com/fastaccess/provider/scheme/LinkParserHelper.java +++ b/app/src/main/java/com/fastaccess/provider/scheme/LinkParserHelper.java @@ -4,6 +4,7 @@ import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; +import android.webkit.MimeTypeMap; import com.annimon.stream.Optional; import com.annimon.stream.Stream; @@ -34,33 +35,35 @@ public class LinkParserHelper { } @NonNull static Uri getBlobBuilder(@NonNull Uri uri) { + boolean isSvg = "svg".equalsIgnoreCase(MimeTypeMap.getFileExtensionFromUrl(uri.toString())); List segments = uri.getPathSegments(); - Uri.Builder urlBuilder = null; - if (uri.getAuthority().equalsIgnoreCase(HOST_DEFAULT)) { - String owner = segments.get(0); - String repo = segments.get(1); - String branch = segments.get(3); - urlBuilder = new Uri.Builder(); - urlBuilder.scheme("https") - .authority(API_AUTHORITY) - .appendPath("repos") - .appendPath(owner) - .appendPath(repo) - .appendPath("contents"); - for (int i = 4; i < segments.size(); i++) { - urlBuilder.appendPath(segments.get(i)); - } - if (uri.getQueryParameterNames() != null) { - for (String query : uri.getQueryParameterNames()) { - urlBuilder.appendQueryParameter(query, uri.getQueryParameter(query)); - } - } - if (uri.getEncodedFragment() != null) { - urlBuilder.encodedFragment(uri.getEncodedFragment()); - } - urlBuilder.appendQueryParameter("ref", branch); + if (isSvg) { + Uri svgBlob = Uri.parse(uri.toString().replace("blob/", "")); + return svgBlob.buildUpon().authority(RAW_AUTHORITY).build(); } - return urlBuilder != null ? urlBuilder.build() : uri; + Uri.Builder urlBuilder = new Uri.Builder(); + String owner = segments.get(0); + String repo = segments.get(1); + String branch = segments.get(3); + urlBuilder.scheme("https") + .authority(API_AUTHORITY) + .appendPath("repos") + .appendPath(owner) + .appendPath(repo) + .appendPath("contents"); + for (int i = 4; i < segments.size(); i++) { + urlBuilder.appendPath(segments.get(i)); + } + if (uri.getQueryParameterNames() != null) { + for (String query : uri.getQueryParameterNames()) { + urlBuilder.appendQueryParameter(query, uri.getQueryParameter(query)); + } + } + if (uri.getEncodedFragment() != null) { + urlBuilder.encodedFragment(uri.getEncodedFragment()); + } + urlBuilder.appendQueryParameter("ref", branch); + return urlBuilder.build(); } public static boolean isEnterprise(@Nullable String url) { diff --git a/app/src/main/java/com/fastaccess/provider/scheme/SchemeParser.java b/app/src/main/java/com/fastaccess/provider/scheme/SchemeParser.java index 5e60502f..9d07961a 100644 --- a/app/src/main/java/com/fastaccess/provider/scheme/SchemeParser.java +++ b/app/src/main/java/com/fastaccess/provider/scheme/SchemeParser.java @@ -131,7 +131,6 @@ public class SchemeParser { if (MarkDownProvider.isArchive(data.toString())) return null; if (TextUtils.equals(authority, HOST_DEFAULT) || TextUtils.equals(authority, RAW_AUTHORITY) || TextUtils.equals(authority, API_AUTHORITY) || isEnterprise) { - Logger.e(data); Intent trending = getTrending(context, data); Intent userIntent = getUser(context, data); Intent repoIssues = getRepoIssueIntent(context, data); @@ -178,6 +177,11 @@ public class SchemeParser { return null; } + private static boolean getInvitationIntent(@NonNull Uri uri) { + List segments = uri.getPathSegments(); + return (segments != null && segments.size() == 3) && "invitations".equalsIgnoreCase(uri.getLastPathSegment()); + } + @Nullable private static Intent getPullRequestIntent(@NonNull Context context, @NonNull Uri uri, boolean showRepoBtn) { List segments = uri.getPathSegments(); if (segments == null || segments.size() < 3) return null; @@ -241,10 +245,23 @@ public class SchemeParser { @Nullable private static Intent getRepo(@NonNull Context context, @NonNull Uri uri) { List segments = uri.getPathSegments(); - if (segments == null || segments.size() < 2 || segments.size() > 2) return null; + if (segments == null || segments.size() < 2 || segments.size() > 3) return null; String owner = segments.get(0); String repoName = segments.get(1); - return RepoPagerActivity.createIntent(context, repoName, owner); + if (segments.size() == 3) { + String lastPath = uri.getLastPathSegment(); + if ("network".equalsIgnoreCase(lastPath)) { + return RepoPagerActivity.createIntent(context, repoName, owner, RepoPagerMvp.CODE, 3); + } else if ("stargazers".equalsIgnoreCase(lastPath)) { + return RepoPagerActivity.createIntent(context, repoName, owner, RepoPagerMvp.CODE, 2); + } else if ("watchers".equalsIgnoreCase(lastPath)) { + return RepoPagerActivity.createIntent(context, repoName, owner, RepoPagerMvp.CODE, 1); + } else { + return null; + } + } else { + return RepoPagerActivity.createIntent(context, repoName, owner); + } } @Nullable private static Intent getWiki(@NonNull Context context, @NonNull Uri uri) { @@ -264,6 +281,9 @@ public class SchemeParser { */ @Nullable private static Intent getGeneralRepo(@NonNull Context context, @NonNull Uri uri) { //TODO parse deeper links to their associate views. meantime fallback to repoPage + if (getInvitationIntent(uri)) { + return null; + } boolean isEnterprise = PrefGetter.isEnterprise() && Uri.parse(LinkParserHelper.getEndpoint(PrefGetter.getEnterpriseUrl())).getAuthority() .equalsIgnoreCase(uri.getAuthority()); if (uri.getAuthority().equals(HOST_DEFAULT) || uri.getAuthority().equals(API_AUTHORITY) || isEnterprise) { @@ -359,6 +379,7 @@ public class SchemeParser { } if (segmentTwo.equals("blob") || segmentTwo.equals("tree")) { Uri urlBuilder = getBlobBuilder(uri); + Logger.e(urlBuilder); return CodeViewerActivity.createIntent(context, urlBuilder.toString(), uri.toString()); } else { String authority = uri.getAuthority(); diff --git a/app/src/main/java/com/fastaccess/provider/tasks/notification/NotificationSchedulerJobTask.java b/app/src/main/java/com/fastaccess/provider/tasks/notification/NotificationSchedulerJobTask.java index aea0a71c..6940d85b 100644 --- a/app/src/main/java/com/fastaccess/provider/tasks/notification/NotificationSchedulerJobTask.java +++ b/app/src/main/java/com/fastaccess/provider/tasks/notification/NotificationSchedulerJobTask.java @@ -1,21 +1,20 @@ + package com.fastaccess.provider.tasks.notification; +import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.media.AudioManager; +import android.os.Build; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.support.v4.content.ContextCompat; import com.annimon.stream.Stream; -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.animation.GlideAnimation; -import com.bumptech.glide.request.target.SimpleTarget; import com.fastaccess.R; import com.fastaccess.data.dao.model.Comment; import com.fastaccess.data.dao.model.Login; @@ -191,21 +190,13 @@ public class NotificationSchedulerJobTask extends JobService { } private void showNotificationWithoutComment(Context context, int accentColor, Notification thread, String iconUrl) { - if (!InputHelper.isEmpty(iconUrl)) { - withoutComments(null, thread, context, accentColor); - } else { - Glide.with(context).load(iconUrl).asBitmap() - .into(new SimpleTarget() { - @Override public void onResourceReady(Bitmap resource, GlideAnimation glideAnimation) { - withoutComments(resource, thread, context, accentColor); - } - }); - } + withoutComments(thread, context, accentColor); } - private void withoutComments(Bitmap bitmap, Notification thread, Context context, int accentColor) { - android.app.Notification toAdd = getNotification(thread.getSubject().getTitle(), thread.getRepository().getFullName()) - .setLargeIcon(bitmap == null ? BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher) : bitmap) + private void withoutComments(Notification thread, Context context, int accentColor) { + android.app.Notification toAdd = getNotification(thread.getSubject().getTitle(), thread.getRepository().getFullName(), + thread.getRepository() != null ? thread.getRepository().getFullName() : "general") + .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher)) .setContentIntent(getPendingIntent(thread.getId(), thread.getSubject().getUrl())) .addAction(R.drawable.ic_github, context.getString(R.string.open), getPendingIntent(thread.getId(), thread .getSubject().getUrl())) @@ -220,20 +211,13 @@ public class NotificationSchedulerJobTask extends JobService { } private void getNotificationWithComment(Context context, int accentColor, Notification thread, Comment comment, String url) { - if (!InputHelper.isEmpty(url)) { - Glide.with(context).load(url).asBitmap().into(new SimpleTarget() { - @Override public void onResourceReady(Bitmap resource, GlideAnimation glideAnimation) { - withComments(resource, comment, context, thread, accentColor); - } - }); - } else { - withComments(null, comment, context, thread, accentColor); - } + withComments(comment, context, thread, accentColor); } - private void withComments(Bitmap bitmap, Comment comment, Context context, Notification thread, int accentColor) { - android.app.Notification toAdd = getNotification(comment.getUser() != null ? comment.getUser().getLogin() : "", comment.getBody()) - .setLargeIcon(bitmap == null ? BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher) : bitmap) + private void withComments(Comment comment, Context context, Notification thread, int accentColor) { + android.app.Notification toAdd = getNotification(comment.getUser() != null ? comment.getUser().getLogin() : "", comment.getBody(), + thread.getRepository() != null ? thread.getRepository().getFullName() : "general") + .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher)) .setSmallIcon(R.drawable.ic_notification) .setStyle(new NotificationCompat.BigTextStyle() .setBigContentTitle(comment.getUser() != null ? comment.getUser().getLogin() : "") @@ -254,7 +238,8 @@ public class NotificationSchedulerJobTask extends JobService { private android.app.Notification getSummaryGroupNotification(@NonNull Notification thread, int accentColor, boolean toNotificationActivity) { PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(), 0, new Intent(getApplicationContext(), NotificationActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); - return getNotification(thread.getSubject().getTitle(), thread.getRepository().getFullName()) + return getNotification(thread.getSubject().getTitle(), thread.getRepository().getFullName(), + thread.getRepository() != null ? thread.getRepository().getFullName() : "general") .setDefaults(PrefGetter.isNotificationSoundEnabled() ? NotificationCompat.DEFAULT_ALL : 0) .setSound(PrefGetter.getNotificationSound(), AudioManager.STREAM_NOTIFICATION) .setContentIntent(toNotificationActivity ? pendingIntent : getPendingIntent(thread.getId(), thread.getSubject().getUrl())) @@ -271,8 +256,8 @@ public class NotificationSchedulerJobTask extends JobService { .build(); } - private NotificationCompat.Builder getNotification(@NonNull String title, @NonNull String message) { - return new NotificationCompat.Builder(this, title) + private NotificationCompat.Builder getNotification(@NonNull String title, @NonNull String message, @NonNull String channelName) { + return new NotificationCompat.Builder(this, channelName) .setContentTitle(title) .setContentText(message) .setAutoCancel(true); @@ -280,7 +265,15 @@ public class NotificationSchedulerJobTask extends JobService { private void showNotification(long id, android.app.Notification notification) { NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(InputHelper.getSafeIntId(id), notification); + if (notificationManager != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel notificationChannel = new NotificationChannel(String.valueOf(id), + notification.getChannelId(), NotificationManager.IMPORTANCE_DEFAULT); + notificationChannel.setShowBadge(true); + notificationManager.createNotificationChannel(notificationChannel); + } + notificationManager.notify(InputHelper.getSafeIntId(id), notification); + } } private PendingIntent getReadOnlyPendingIntent(long id, @NonNull String url) { diff --git a/app/src/main/java/com/fastaccess/provider/tasks/version/CheckVersionService.kt b/app/src/main/java/com/fastaccess/provider/tasks/version/CheckVersionService.kt index 4f28bd14..a40a753b 100644 --- a/app/src/main/java/com/fastaccess/provider/tasks/version/CheckVersionService.kt +++ b/app/src/main/java/com/fastaccess/provider/tasks/version/CheckVersionService.kt @@ -3,6 +3,7 @@ package com.fastaccess.provider.tasks.version import android.app.IntentService import android.content.Intent import android.widget.Toast +import com.fastaccess.App import com.fastaccess.BuildConfig import com.fastaccess.R import com.fastaccess.data.dao.model.Release @@ -19,7 +20,7 @@ class CheckVersionService : IntentService("CheckVersionService") { .getLatestRelease("k0shk0sh", "FastHub")) .subscribe({ t: Release? -> t?.let { - Toast.makeText(this, if (BuildConfig.VERSION_NAME.contains(it.tagName)) + Toast.makeText(App.getInstance(), if (BuildConfig.VERSION_NAME.contains(it.tagName)) R.string.up_to_date else R.string.new_version, Toast.LENGTH_LONG).show() } }, { throwable -> throwable.printStackTrace() }) diff --git a/app/src/main/java/com/fastaccess/provider/theme/ThemeEngine.kt b/app/src/main/java/com/fastaccess/provider/theme/ThemeEngine.kt index 81477840..74d30329 100644 --- a/app/src/main/java/com/fastaccess/provider/theme/ThemeEngine.kt +++ b/app/src/main/java/com/fastaccess/provider/theme/ThemeEngine.kt @@ -13,7 +13,6 @@ import com.fastaccess.ui.base.BaseActivity import com.fastaccess.ui.modules.login.LoginActivity import com.fastaccess.ui.modules.login.chooser.LoginChooserActivity import com.fastaccess.ui.modules.main.donation.DonateActivity -import com.fastaccess.ui.modules.reviews.changes.ReviewChangesActivity /** * Created by Kosh on 07 Jun 2017, 6:52 PM @@ -44,7 +43,7 @@ object ThemeEngine { PrefGetter.LIGHT -> activity.setTheme(R.style.AppTheme_AboutActivity_Light) PrefGetter.DARK -> activity.setTheme(R.style.AppTheme_AboutActivity_Dark) PrefGetter.AMLOD -> activity.setTheme(R.style.AppTheme_AboutActivity_Amlod) - PrefGetter.MID_NIGHT_BLUE -> activity.setTheme(R.style.AppTheme_AboutActivity_MidNightBlue) + PrefGetter.MID_NIGHT_BLUE -> activity.setTheme(R.style.AppTheme_AboutActivity_Midnight) PrefGetter.BLUISH -> activity.setTheme(R.style.AppTheme_AboutActivity_Bluish) } setTaskDescription(activity) @@ -89,7 +88,8 @@ object ThemeEngine { PrefGetter.BLUE -> return R.style.ThemeDark PrefGetter.LIGHT_BLUE -> return R.style.ThemeDark_LightBlue PrefGetter.CYAN -> return R.style.ThemeDark_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.ThemeDark_Green + PrefGetter.GREEN -> return R.style.ThemeDark_Green + PrefGetter.TEAL -> return R.style.ThemeDark_Teal PrefGetter.LIGHT_GREEN -> return R.style.ThemeDark_LightGreen PrefGetter.LIME -> return R.style.ThemeDark_Lime PrefGetter.YELLOW -> return R.style.ThemeDark_Yellow @@ -107,7 +107,8 @@ object ThemeEngine { PrefGetter.BLUE -> return R.style.ThemeAmlod PrefGetter.LIGHT_BLUE -> return R.style.ThemeAmlod_LightBlue PrefGetter.CYAN -> return R.style.ThemeAmlod_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.ThemeAmlod_Green + PrefGetter.TEAL -> return R.style.ThemeAmlod_Teal + PrefGetter.GREEN -> return R.style.ThemeAmlod_Green PrefGetter.LIGHT_GREEN -> return R.style.ThemeAmlod_LightGreen PrefGetter.LIME -> return R.style.ThemeAmlod_Lime PrefGetter.YELLOW -> return R.style.ThemeAmlod_Yellow @@ -117,22 +118,23 @@ object ThemeEngine { else -> return R.style.ThemeAmlod } PrefGetter.MID_NIGHT_BLUE -> when (themeColor) { - PrefGetter.RED -> return R.style.ThemeMidNighBlue_Red - PrefGetter.PINK -> return R.style.ThemeMidNighBlue_Pink - PrefGetter.PURPLE -> return R.style.ThemeMidNighBlue_Purple - PrefGetter.DEEP_PURPLE -> return R.style.ThemeMidNighBlue_DeepPurple - PrefGetter.INDIGO -> return R.style.ThemeMidNighBlue_Indigo - PrefGetter.BLUE -> return R.style.ThemeMidNighBlue - PrefGetter.LIGHT_BLUE -> return R.style.ThemeMidNighBlue_LightBlue - PrefGetter.CYAN -> return R.style.ThemeMidNighBlue_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.ThemeMidNighBlue_Green - PrefGetter.LIGHT_GREEN -> return R.style.ThemeMidNighBlue_LightGreen - PrefGetter.LIME -> return R.style.ThemeMidNighBlue_Lime - PrefGetter.YELLOW -> return R.style.ThemeMidNighBlue_Yellow - PrefGetter.AMBER -> return R.style.ThemeMidNighBlue_Amber - PrefGetter.ORANGE -> return R.style.ThemeMidNighBlue_Orange - PrefGetter.DEEP_ORANGE -> return R.style.ThemeMidNighBlue_DeepOrange - else -> return R.style.ThemeMidNighBlue + PrefGetter.RED -> return R.style.ThemeMidnight_Red + PrefGetter.PINK -> return R.style.ThemeMidnight_Pink + PrefGetter.PURPLE -> return R.style.ThemeMidnight_Purple + PrefGetter.DEEP_PURPLE -> return R.style.ThemeMidnight_DeepPurple + PrefGetter.INDIGO -> return R.style.ThemeMidnight_Indigo + PrefGetter.BLUE -> return R.style.ThemeMidnight + PrefGetter.LIGHT_BLUE -> return R.style.ThemeMidnight_LightBlue + PrefGetter.CYAN -> return R.style.ThemeMidnight_Cyan + PrefGetter.TEAL -> return R.style.ThemeMidnight_Teal + PrefGetter.GREEN -> return R.style.ThemeMidnight_Green + PrefGetter.LIGHT_GREEN -> return R.style.ThemeMidnight_LightGreen + PrefGetter.LIME -> return R.style.ThemeMidnight_Lime + PrefGetter.YELLOW -> return R.style.ThemeMidnight_Yellow + PrefGetter.AMBER -> return R.style.ThemeMidnight_Amber + PrefGetter.ORANGE -> return R.style.ThemeMidnight_Orange + PrefGetter.DEEP_ORANGE -> return R.style.ThemeMidnight_DeepOrange + else -> return R.style.ThemeMidnight } PrefGetter.BLUISH -> when (themeColor) { PrefGetter.RED -> return R.style.ThemeBluish_Red @@ -143,7 +145,8 @@ object ThemeEngine { PrefGetter.BLUE -> return R.style.ThemeBluish PrefGetter.LIGHT_BLUE -> return R.style.ThemeBluish_LightBlue PrefGetter.CYAN -> return R.style.ThemeBluish_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.ThemeBluish_Green + PrefGetter.TEAL -> return R.style.ThemeBluish_Teal + PrefGetter.GREEN -> return R.style.ThemeBluish_Green PrefGetter.LIGHT_GREEN -> return R.style.ThemeBluish_LightGreen PrefGetter.LIME -> return R.style.ThemeBluish_Lime PrefGetter.YELLOW -> return R.style.ThemeBluish_Yellow @@ -186,7 +189,8 @@ object ThemeEngine { PrefGetter.BLUE -> return R.style.DialogThemeDark PrefGetter.LIGHT_BLUE -> return R.style.DialogThemeDark_LightBlue PrefGetter.CYAN -> return R.style.DialogThemeDark_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.DialogThemeDark_Green + PrefGetter.TEAL -> return R.style.DialogThemeDark_Teal + PrefGetter.GREEN -> return R.style.DialogThemeDark_Green PrefGetter.LIGHT_GREEN -> return R.style.DialogThemeDark_LightGreen PrefGetter.LIME -> return R.style.DialogThemeDark_Lime PrefGetter.YELLOW -> return R.style.DialogThemeDark_Yellow @@ -204,7 +208,8 @@ object ThemeEngine { PrefGetter.BLUE -> return R.style.DialogThemeAmlod PrefGetter.LIGHT_BLUE -> return R.style.DialogThemeAmlod_LightBlue PrefGetter.CYAN -> return R.style.DialogThemeAmlod_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.DialogThemeAmlod_Green + PrefGetter.TEAL -> return R.style.DialogThemeAmlod_Teal + PrefGetter.GREEN -> return R.style.DialogThemeAmlod_Green PrefGetter.LIGHT_GREEN -> return R.style.DialogThemeAmlod_LightGreen PrefGetter.LIME -> return R.style.DialogThemeAmlod_Lime PrefGetter.YELLOW -> return R.style.DialogThemeAmlod_Yellow @@ -214,21 +219,22 @@ object ThemeEngine { else -> return R.style.DialogThemeAmlod } PrefGetter.MID_NIGHT_BLUE -> when (themeColor) { - PrefGetter.RED -> return R.style.DialogThemeLight_Red - PrefGetter.PINK -> return R.style.DialogThemeLight_Pink - PrefGetter.PURPLE -> return R.style.DialogThemeLight_Purple - PrefGetter.DEEP_PURPLE -> return R.style.DialogThemeLight_DeepPurple - PrefGetter.INDIGO -> return R.style.DialogThemeLight_Indigo - PrefGetter.BLUE -> return R.style.DialogThemeLight - PrefGetter.LIGHT_BLUE -> return R.style.DialogThemeLight_LightBlue - PrefGetter.CYAN -> return R.style.DialogThemeLight_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.DialogThemeLight_Green - PrefGetter.LIGHT_GREEN -> return R.style.DialogThemeLight_LightGreen - PrefGetter.LIME -> return R.style.DialogThemeLight_Lime - PrefGetter.YELLOW -> return R.style.DialogThemeLight_Yellow - PrefGetter.AMBER -> return R.style.DialogThemeLight_Amber - PrefGetter.ORANGE -> return R.style.DialogThemeLight_Orange - PrefGetter.DEEP_ORANGE -> return R.style.DialogThemeLight_DeepOrange + PrefGetter.RED -> return R.style.DialogThemeMidnight_Red + PrefGetter.PINK -> return R.style.DialogThemeMidnight_Pink + PrefGetter.PURPLE -> return R.style.DialogThemeMidnight_Purple + PrefGetter.DEEP_PURPLE -> return R.style.DialogThemeMidnight_DeepPurple + PrefGetter.INDIGO -> return R.style.DialogThemeMidnight_Indigo + PrefGetter.BLUE -> return R.style.DialogThemeMidnight + PrefGetter.LIGHT_BLUE -> return R.style.DialogThemeMidnight_LightBlue + PrefGetter.CYAN -> return R.style.DialogThemeMidnight_Cyan + PrefGetter.TEAL -> return R.style.DialogThemeMidnight_Teal + PrefGetter.GREEN -> return R.style.DialogThemeMidnight_Green + PrefGetter.LIGHT_GREEN -> return R.style.DialogThemeMidnight_LightGreen + PrefGetter.LIME -> return R.style.DialogThemeMidnight_Lime + PrefGetter.YELLOW -> return R.style.DialogThemeMidnight_Yellow + PrefGetter.AMBER -> return R.style.DialogThemeMidnight_Amber + PrefGetter.ORANGE -> return R.style.DialogThemeMidnight_Orange + PrefGetter.DEEP_ORANGE -> return R.style.DialogThemeMidnight_DeepOrange else -> return R.style.DialogThemeLight } PrefGetter.BLUISH -> when (themeColor) { @@ -240,7 +246,8 @@ object ThemeEngine { PrefGetter.BLUE -> return R.style.DialogThemeBluish PrefGetter.LIGHT_BLUE -> return R.style.DialogThemeBluish_LightBlue PrefGetter.CYAN -> return R.style.DialogThemeBluish_Cyan - PrefGetter.TEAL, PrefGetter.GREEN -> return R.style.DialogThemeBluish_Green + PrefGetter.TEAL -> return R.style.DialogThemeBluish_Teal + PrefGetter.GREEN -> return R.style.DialogThemeBluish_Green PrefGetter.LIGHT_GREEN -> return R.style.DialogThemeBluish_LightGreen PrefGetter.LIME -> return R.style.DialogThemeBluish_Lime PrefGetter.YELLOW -> return R.style.DialogThemeBluish_Yellow @@ -259,5 +266,5 @@ object ThemeEngine { } private fun hasTheme(activity: BaseActivity<*, *>) = (activity is LoginChooserActivity || activity is LoginActivity || - activity is DonateActivity || activity is ReviewChangesActivity) + activity is DonateActivity) } \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/provider/timeline/HtmlHelper.java b/app/src/main/java/com/fastaccess/provider/timeline/HtmlHelper.java index 7de7c038..88f97f1e 100644 --- a/app/src/main/java/com/fastaccess/provider/timeline/HtmlHelper.java +++ b/app/src/main/java/com/fastaccess/provider/timeline/HtmlHelper.java @@ -47,13 +47,15 @@ public class HtmlHelper { public static void htmlIntoTextView(@NonNull TextView textView, @NonNull String html, int width) { registerClickEvent(textView); - if (textView.getMeasuredWidth() > 0) { + if (textView.getWidth() > 100) { textView.setText(initHtml(textView, getActualWidth(textView)).fromHtml(format(html).toString())); } else { textView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { textView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - textView.setText(initHtml(textView, getActualWidth(textView)).fromHtml(format(html).toString())); + textView.setText(initHtml(textView, textView.getWidth() > 100 ? getActualWidth(textView) : (width + convertDpToPx(textView + .getContext(), 16))) + .fromHtml(format(html).toString())); } }); } @@ -92,7 +94,7 @@ public class HtmlHelper { } private static int getActualWidth(TextView textView) { - return textView.getMeasuredWidth() - (convertDpToPx(textView.getContext(), 16)); + return textView.getWidth() - (convertDpToPx(textView.getContext(), 16)); } private static HtmlSpanner initHtml(@NonNull TextView textView, int width) { diff --git a/app/src/main/java/com/fastaccess/provider/timeline/TimelineProvider.java b/app/src/main/java/com/fastaccess/provider/timeline/TimelineProvider.java index 9b93c6cc..708d6360 100644 --- a/app/src/main/java/com/fastaccess/provider/timeline/TimelineProvider.java +++ b/app/src/main/java/com/fastaccess/provider/timeline/TimelineProvider.java @@ -86,7 +86,8 @@ public class TimelineProvider { } else if (event == IssueEventType.assigned || event == IssueEventType.unassigned) { spannableBuilder .append(" "); - if (user != null && user.getLogin().equalsIgnoreCase(issueEventModel.getAssignee().getLogin())) { + if ((user != null && issueEventModel.getAssignee() != null) && user.getLogin() + .equalsIgnoreCase(issueEventModel.getAssignee().getLogin())) { spannableBuilder .append(event == IssueEventType.assigned ? "self-assigned this" : "removed their assignment"); } else { @@ -94,7 +95,7 @@ public class TimelineProvider { .append(event == IssueEventType.assigned ? "assigned" : "unassigned"); spannableBuilder .append(" ") - .bold(issueEventModel.getAssignee().getLogin()); + .bold(issueEventModel.getAssignee() != null ? issueEventModel.getAssignee().getLogin() : ""); } } else if (event == IssueEventType.locked || event == IssueEventType.unlocked) { spannableBuilder @@ -133,11 +134,13 @@ public class TimelineProvider { } else if (event == IssueEventType.cross_referenced) { SourceModel sourceModel = issueEventModel.getSource(); if (sourceModel != null) { + String type = sourceModel.getType(); SpannableBuilder title = SpannableBuilder.builder(); - if (sourceModel.getIssue() != null) { + if (sourceModel.getPullRequest() != null) { + if (sourceModel.getIssue() != null) title.url("#" + sourceModel.getIssue().getNumber()); + type = "pull request"; + } else if (sourceModel.getIssue() != null) { title.url("#" + sourceModel.getIssue().getNumber()); - } else if (sourceModel.getPullRequest() != null) { - title.url("#" + sourceModel.getPullRequest().getNumber()); } else if (sourceModel.getCommit() != null) { title.url(substring(sourceModel.getCommit().getSha())); } else if (sourceModel.getRepository() != null) { @@ -147,7 +150,7 @@ public class TimelineProvider { spannableBuilder.append(" ") .append(thisString) .append(" in ") - .append(sourceModel.getType()) + .append(type) .append(" ") .append(title); } diff --git a/app/src/main/java/com/fastaccess/provider/timeline/handler/DrawableHandler.java b/app/src/main/java/com/fastaccess/provider/timeline/handler/DrawableHandler.java index ad479246..21fa616c 100644 --- a/app/src/main/java/com/fastaccess/provider/timeline/handler/DrawableHandler.java +++ b/app/src/main/java/com/fastaccess/provider/timeline/handler/DrawableHandler.java @@ -8,7 +8,6 @@ import com.fastaccess.helper.InputHelper; import com.fastaccess.provider.timeline.handler.drawable.DrawableGetter; import net.nightwhistler.htmlspanner.TagNodeHandler; -import net.nightwhistler.htmlspanner.spans.CenterSpan; import org.htmlcleaner.TagNode; @@ -33,9 +32,7 @@ import static android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; if (!InputHelper.isEmpty(src)) { builder.append(""); if (isNull()) return; - CenterSpan centerSpan = new CenterSpan(); DrawableGetter imageGetter = new DrawableGetter(textView); - builder.setSpan(centerSpan, start, builder.length(), SPAN_EXCLUSIVE_EXCLUSIVE); builder.setSpan(new ImageSpan(imageGetter.getDrawable(src)), start, builder.length(), SPAN_EXCLUSIVE_EXCLUSIVE); appendNewLine(builder); } diff --git a/app/src/main/java/com/fastaccess/provider/timeline/handler/TableHandler.java b/app/src/main/java/com/fastaccess/provider/timeline/handler/TableHandler.java index ff1bab2d..a3d1a537 100644 --- a/app/src/main/java/com/fastaccess/provider/timeline/handler/TableHandler.java +++ b/app/src/main/java/com/fastaccess/provider/timeline/handler/TableHandler.java @@ -144,12 +144,13 @@ public class TableHandler extends TagNodeHandler { int rowHeight = 0; - for (Spanned cell : row) { - StaticLayout layout = new StaticLayout(cell, textPaint, columnWidth - - 2 * PADDING, Alignment.ALIGN_NORMAL, 1f, 0f, true); - - if (layout.getHeight() > rowHeight) { - rowHeight = layout.getHeight(); + if (columnWidth > 0) { + for (Spanned cell : row) { + StaticLayout layout = new StaticLayout(cell, textPaint, columnWidth + - 2 * PADDING, Alignment.ALIGN_NORMAL, 1f, 0f, true); + if (layout.getHeight() > rowHeight) { + rowHeight = layout.getHeight(); + } } } diff --git a/app/src/main/java/com/fastaccess/provider/timeline/handler/drawable/GlideDrawableTarget.java b/app/src/main/java/com/fastaccess/provider/timeline/handler/drawable/GlideDrawableTarget.java index 428bab2d..35ff6c95 100644 --- a/app/src/main/java/com/fastaccess/provider/timeline/handler/drawable/GlideDrawableTarget.java +++ b/app/src/main/java/com/fastaccess/provider/timeline/handler/drawable/GlideDrawableTarget.java @@ -8,6 +8,7 @@ import com.bumptech.glide.load.resource.drawable.GlideDrawable; import com.bumptech.glide.request.animation.GlideAnimation; import com.bumptech.glide.request.target.SimpleTarget; import com.fastaccess.R; +import com.fastaccess.helper.PrefGetter; import java.lang.ref.WeakReference; @@ -23,22 +24,13 @@ class GlideDrawableTarget extends SimpleTarget { @Override public void onResourceReady(GlideDrawable resource, GlideAnimation glideAnimation) { if (container != null && container.get() != null) { TextView textView = container.get(); - float width; - float height; - if (resource.getIntrinsicWidth() >= textView.getWidth()) { - float downScale = (float) resource.getIntrinsicWidth() / textView.getWidth(); - width = (float) resource.getIntrinsicWidth() / downScale; - height = (float) resource.getIntrinsicHeight() / downScale; - } else { - float multiplier = (float) textView.getWidth() / resource.getIntrinsicWidth(); - width = (float) resource.getIntrinsicWidth() * multiplier; - height = (float) resource.getIntrinsicHeight() * multiplier; - } - Rect rect = new Rect(0, 0, (int) Math.round(width / 1.3), (int) Math.round(height / 1.3)); + float width = (float) (resource.getIntrinsicWidth() / 1.3); + float height = (float) (resource.getIntrinsicHeight() / 1.3); + Rect rect = new Rect(0, 0, Math.round(width), Math.round(height)); resource.setBounds(rect); urlDrawable.setBounds(rect); urlDrawable.setDrawable(resource); - if (resource.isAnimated()) { + if (resource.isAnimated() && !PrefGetter.isGistDisabled()) { urlDrawable.setCallback((Drawable.Callback) textView.getTag(R.id.drawable_callback)); resource.setLoopCount(GlideDrawable.LOOP_FOREVER); resource.start(); diff --git a/app/src/main/java/com/fastaccess/ui/adapter/ColumnCardAdapter.kt b/app/src/main/java/com/fastaccess/ui/adapter/ColumnCardAdapter.kt new file mode 100644 index 00000000..25d93947 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/adapter/ColumnCardAdapter.kt @@ -0,0 +1,21 @@ +package com.fastaccess.ui.adapter + +import android.view.ViewGroup +import com.fastaccess.data.dao.ProjectCardModel +import com.fastaccess.ui.adapter.viewholder.ColumnCardViewHolder +import com.fastaccess.ui.widgets.recyclerview.BaseRecyclerAdapter +import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder + +/** + * Created by Hashemsergani on 11.09.17. + */ +class ColumnCardAdapter(date: ArrayList, val isOwner: Boolean) : BaseRecyclerAdapter>(date) { + + override fun viewHolder(parent: ViewGroup, viewType: Int): ColumnCardViewHolder = ColumnCardViewHolder.newInstance(parent, this, isOwner) + + override fun onBindView(holder: ColumnCardViewHolder?, position: Int) { + holder?.bind(data[position]) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/adapter/EmojiAdapter.kt b/app/src/main/java/com/fastaccess/ui/adapter/EmojiAdapter.kt index d1041c51..debe140e 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/EmojiAdapter.kt +++ b/app/src/main/java/com/fastaccess/ui/adapter/EmojiAdapter.kt @@ -47,8 +47,11 @@ class EmojiAdapter(listener: BaseViewHolder.OnItemClickListener) return results } + @Suppress("UNCHECKED_CAST") override fun publishResults(var1: CharSequence, results: Filter.FilterResults) { - insertItems(results.values as List) + results.values?.let { + insertItems(it as List) + } } } } diff --git a/app/src/main/java/com/fastaccess/ui/adapter/ProfilePinnedReposAdapter.kt b/app/src/main/java/com/fastaccess/ui/adapter/ProfilePinnedReposAdapter.kt index eb14b699..de64981f 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/ProfilePinnedReposAdapter.kt +++ b/app/src/main/java/com/fastaccess/ui/adapter/ProfilePinnedReposAdapter.kt @@ -4,7 +4,7 @@ import android.view.ViewGroup import com.fastaccess.ui.adapter.viewholder.ProfilePinnedReposViewHolder import com.fastaccess.ui.widgets.recyclerview.BaseRecyclerAdapter import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder -import pr.GetPinnedReposQuery +import github.GetPinnedReposQuery import java.text.NumberFormat /** diff --git a/app/src/main/java/com/fastaccess/ui/adapter/ProjectsAdapter.kt b/app/src/main/java/com/fastaccess/ui/adapter/ProjectsAdapter.kt new file mode 100644 index 00000000..b56807dd --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/adapter/ProjectsAdapter.kt @@ -0,0 +1,20 @@ +package com.fastaccess.ui.adapter + +import android.view.ViewGroup +import com.fastaccess.ui.adapter.viewholder.ProjectViewHolder +import com.fastaccess.ui.widgets.recyclerview.BaseRecyclerAdapter +import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder +import github.RepoProjectsOpenQuery + +/** + * Created by kosh on 09/09/2017. + */ +class ProjectsAdapter(data: ArrayList) : + BaseRecyclerAdapter>(data) { + + override fun viewHolder(parent: ViewGroup, viewType: Int): ProjectViewHolder = ProjectViewHolder.newInstance(parent, this) + + override fun onBindView(holder: ProjectViewHolder?, position: Int) { + holder?.bind(data[position]) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ColumnCardViewHolder.kt b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ColumnCardViewHolder.kt new file mode 100644 index 00000000..d568f237 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ColumnCardViewHolder.kt @@ -0,0 +1,37 @@ +package com.fastaccess.ui.adapter.viewholder + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import butterknife.BindView +import com.fastaccess.R +import com.fastaccess.data.dao.ProjectCardModel +import com.fastaccess.ui.widgets.recyclerview.BaseRecyclerAdapter +import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder + +/** + * Created by Hashemsergani on 11.09.17. + */ +class ColumnCardViewHolder private constructor(item: View, adapter: BaseRecyclerAdapter<*, *, *>, val isOwner: Boolean) + : BaseViewHolder(item, adapter) { + + @BindView(R.id.title) lateinit var title: TextView + @BindView(R.id.addedBy) lateinit var addedBy: TextView + @BindView(R.id.editCard) lateinit var editCard: View + + init { + editCard.setOnClickListener(this) + } + + override fun bind(t: ProjectCardModel) { + title.text = t.note + addedBy.text = itemView.context.getString(R.string.card_added_by, t.creator?.login) + editCard.visibility = if (isOwner) View.VISIBLE else View.GONE + } + + companion object { + fun newInstance(parent: ViewGroup, adapter: BaseRecyclerAdapter<*, *, *>, isOwner: Boolean): ColumnCardViewHolder { + return ColumnCardViewHolder(getView(parent, R.layout.column_card_row_layout), adapter, isOwner) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitCommentsViewHolder.kt b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitCommentsViewHolder.kt index 9cf75c62..6b26f0d5 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitCommentsViewHolder.kt +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitCommentsViewHolder.kt @@ -4,6 +4,7 @@ import android.support.transition.ChangeBounds import android.support.transition.TransitionManager import android.view.View import android.view.ViewGroup +import android.widget.TextView import butterknife.BindView import com.fastaccess.R import com.fastaccess.data.dao.model.Comment @@ -43,6 +44,7 @@ class CommitCommentsViewHolder private constructor(view: View, adapter: BaseRecy @BindView(R.id.commentMenu) lateinit var commentMenu: ForegroundImageView @BindView(R.id.comment) lateinit var comment: FontTextView @BindView(R.id.commentOptions) lateinit var commentOptions: View + @BindView(R.id.owner) lateinit var owner: TextView override fun onClick(v: View) { if (v.id == R.id.toggle || v.id == R.id.toggleHolder) { @@ -68,6 +70,12 @@ class CommitCommentsViewHolder private constructor(view: View, adapter: BaseRecy } else { comment.text = "" } + if (t.authorAssociation != null && !"none".equals(t.authorAssociation, ignoreCase = true)) { + owner.text = t.authorAssociation.toLowerCase() + owner.visibility = View.VISIBLE + } else { + owner.visibility = View.GONE + } if (t.createdAt == t.updatedAt) { date.text = String.format("%s %s", ParseDateFormat.getTimeAgo(t.updatedAt), itemView .resources.getString(R.string.edited)) diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitLinesViewHolder.java b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitLinesViewHolder.java index f6e3d883..c4de7aaf 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitLinesViewHolder.java +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/CommitLinesViewHolder.java @@ -26,6 +26,7 @@ public class CommitLinesViewHolder extends BaseViewHolder { @BindView(R.id.textView) AppCompatTextView textView; @BindView(R.id.leftLinNo) AppCompatTextView leftLinNo; @BindView(R.id.rightLinNo) AppCompatTextView rightLinNo; + @BindView(R.id.hasComment) View hasComment; private final int patchAdditionColor; private final int patchDeletionColor; private final int patchRefColor; @@ -44,6 +45,7 @@ public class CommitLinesViewHolder extends BaseViewHolder { @Override public void bind(@NonNull CommitLinesModel item) { leftLinNo.setText(item.getLeftLineNo() > 0 ? String.valueOf(item.getLeftLineNo()) : " "); rightLinNo.setText(item.getRightLineNo() > 0 ? String.valueOf(item.getRightLineNo()) : " "); + hasComment.setVisibility(item.isHasCommentedOn() ? View.VISIBLE : View.GONE); switch (item.getColor()) { case CommitLinesModel.ADDITION: textView.setBackgroundColor(patchAdditionColor); diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/IssueTimelineViewHolder.java b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/IssueTimelineViewHolder.java index bb7ca244..01e94d5e 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/IssueTimelineViewHolder.java +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/IssueTimelineViewHolder.java @@ -47,12 +47,17 @@ public class IssueTimelineViewHolder extends BaseViewHolder { avatarLayout.setUrl(issueEventModel.getAssigner().getAvatarUrl(), issueEventModel.getAssigner().getLogin(), false, LinkParserHelper.isEnterprise(issueEventModel.getUrl())); } else { - if (issueEventModel.getActor() != null) { - avatarLayout.setUrl(issueEventModel.getActor().getAvatarUrl(), issueEventModel.getActor().getLogin(), - false, LinkParserHelper.isEnterprise(issueEventModel.getUrl())); - } else if (issueEventModel.getAuthor() != null) { - avatarLayout.setUrl(issueEventModel.getAuthor().getAvatarUrl(), issueEventModel.getAuthor().getLogin(), - false, LinkParserHelper.isEnterprise(issueEventModel.getUrl())); + if (event != IssueEventType.committed) { + avatarLayout.setVisibility(View.VISIBLE); + if (issueEventModel.getActor() != null) { + avatarLayout.setUrl(issueEventModel.getActor().getAvatarUrl(), issueEventModel.getActor().getLogin(), + false, LinkParserHelper.isEnterprise(issueEventModel.getUrl())); + } else if (issueEventModel.getAuthor() != null) { + avatarLayout.setUrl(issueEventModel.getAuthor().getAvatarUrl(), issueEventModel.getAuthor().getLogin(), + false, LinkParserHelper.isEnterprise(issueEventModel.getUrl())); + } + } else { + avatarLayout.setVisibility(View.GONE); } } if (event != null) { diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ProfilePinnedReposViewHolder.kt b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ProfilePinnedReposViewHolder.kt index f26e124e..e050efcb 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ProfilePinnedReposViewHolder.kt +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ProfilePinnedReposViewHolder.kt @@ -8,7 +8,7 @@ import com.fastaccess.R import com.fastaccess.ui.widgets.FontTextView import com.fastaccess.ui.widgets.recyclerview.BaseRecyclerAdapter import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder -import pr.GetPinnedReposQuery +import github.GetPinnedReposQuery import java.text.NumberFormat /** diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ProjectViewHolder.kt b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ProjectViewHolder.kt new file mode 100644 index 00000000..5a3a8f09 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ProjectViewHolder.kt @@ -0,0 +1,38 @@ +package com.fastaccess.ui.adapter.viewholder + +import android.view.View +import android.view.ViewGroup +import butterknife.BindView +import com.fastaccess.R +import com.fastaccess.helper.ParseDateFormat +import com.fastaccess.ui.widgets.FontTextView +import com.fastaccess.ui.widgets.recyclerview.BaseRecyclerAdapter +import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder +import github.RepoProjectsOpenQuery + +/** + * Created by kosh on 09/09/2017. + */ +class ProjectViewHolder(view: View, adapter: BaseRecyclerAdapter<*, *, *>) : BaseViewHolder(view, adapter) { + + @BindView(R.id.description) lateinit var description: FontTextView + @BindView(R.id.title) lateinit var title: FontTextView + @BindView(R.id.date) lateinit var date: FontTextView + + override fun bind(t: RepoProjectsOpenQuery.Node) { + title.text = t.name() + if (t.body().isNullOrBlank()) { + description.visibility = View.GONE + } else { + description.visibility = View.VISIBLE + description.text = t.body() + } + date.text = ParseDateFormat.getTimeAgo(t.createdAt().toString()) + } + + companion object { + fun newInstance(parent: ViewGroup, adapter: BaseRecyclerAdapter<*, *, *>): ProjectViewHolder { + return ProjectViewHolder(getView(parent, R.layout.feeds_row_no_image_item), adapter) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestEventViewHolder.kt b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestEventViewHolder.kt index c6f1d8ea..6b29ed9e 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestEventViewHolder.kt +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestEventViewHolder.kt @@ -21,8 +21,8 @@ import com.fastaccess.ui.widgets.SpannableBuilder import com.fastaccess.ui.widgets.recyclerview.BaseRecyclerAdapter import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder import com.zzhoujay.markdown.style.CodeSpan -import pr.PullRequestTimelineQuery -import pr.type.StatusState +import github.PullRequestTimelineQuery +import github.type.StatusState /** * Created by kosh on 03/08/2017. diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestTimelineCommentsViewHolder.java b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestTimelineCommentsViewHolder.java index 36512e87..08133991 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestTimelineCommentsViewHolder.java +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullRequestTimelineCommentsViewHolder.java @@ -30,8 +30,8 @@ import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder; import java.util.List; import butterknife.BindView; -import pr.PullRequestTimelineQuery; -import pr.type.ReactionContent; +import github.PullRequestTimelineQuery; +import github.type.ReactionContent; /** * Created by Kosh on 11 Nov 2016, 2:08 PM diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullStatusViewHolder.java b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullStatusViewHolder.java index 0ba3815f..6bb63784 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullStatusViewHolder.java +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/PullStatusViewHolder.java @@ -11,6 +11,7 @@ import com.fastaccess.R; import com.fastaccess.data.dao.PullRequestStatusModel; import com.fastaccess.data.dao.types.StatusStateType; import com.fastaccess.helper.InputHelper; +import com.fastaccess.helper.Logger; import com.fastaccess.provider.scheme.SchemeParser; import com.fastaccess.ui.widgets.FontTextView; import com.fastaccess.ui.widgets.ForegroundImageView; @@ -44,12 +45,20 @@ public class PullStatusViewHolder extends BaseViewHolder } @Override public void bind(@NonNull PullRequestStatusModel pullRequestStatusModel) { + Logger.e(pullRequestStatusModel.getState(), pullRequestStatusModel.isMergable()); if (pullRequestStatusModel.getState() != null) { StatusStateType stateType = pullRequestStatusModel.getState(); stateImage.setImageResource(stateType.getDrawableRes()); if (stateType == StatusStateType.failure) { stateImage.tintDrawableColor(red); - status.setText(R.string.checks_failed); + if (pullRequestStatusModel.isMergable()) { + status.setText(R.string.checks_failed); + } else { + status.setText(SpannableBuilder.builder() + .append(status.getResources().getString(R.string.checks_failed)) + .append("\n") + .append(status.getResources().getString(R.string.can_not_merge_pr))); + } } else if (stateType == StatusStateType.pending) { if (pullRequestStatusModel.isMergable()) { stateImage.setImageResource(R.drawable.ic_check_small); @@ -72,16 +81,14 @@ public class PullStatusViewHolder extends BaseViewHolder if (pullRequestStatusModel.getStatuses() != null && !pullRequestStatusModel.getStatuses().isEmpty()) { SpannableBuilder builder = SpannableBuilder.builder(); Stream.of(pullRequestStatusModel.getStatuses()) - .filter(statusesModel -> statusesModel.getState() != null) + .filter(statusesModel -> statusesModel != null && statusesModel.getState() != null && statusesModel.getTargetUrl() != null) .forEach(statusesModel -> { - builder.append(ContextCompat.getDrawable(statuses.getContext(), statusesModel.getState().getDrawableRes())); if (!InputHelper.isEmpty(statusesModel.getTargetUrl())) { + builder.append(ContextCompat.getDrawable(statuses.getContext(), statusesModel.getState().getDrawableRes())); builder.append(" ") .append(statusesModel.getContext() != null ? statusesModel.getContext() + " " : "") .url(statusesModel.getDescription(), v -> SchemeParser.launchUri(v.getContext(), statusesModel.getTargetUrl())) .append("\n"); - } else { - builder.append("\n"); } }); if (!InputHelper.isEmpty(builder)) { diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewCommentsViewHolder.java b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewCommentsViewHolder.java index 5753b250..dd6eeff4 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewCommentsViewHolder.java +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewCommentsViewHolder.java @@ -108,18 +108,23 @@ public class ReviewCommentsViewHolder extends BaseViewHolder avatarView.setUrl(commentModel.getUser().getAvatarUrl(), commentModel.getUser().getLogin(), commentModel.getUser() .isOrganizationType(), LinkParserHelper.isEnterprise(commentModel.getHtmlUrl())); name.setText(commentModel.getUser().getLogin()); - boolean isRepoOwner = TextUtils.equals(commentModel.getUser().getLogin(), repoOwner); - if (isRepoOwner) { + if (commentModel.getAuthorAssociation() != null && !"none".equalsIgnoreCase(commentModel.getAuthorAssociation())) { + owner.setText(commentModel.getAuthorAssociation().toLowerCase()); owner.setVisibility(View.VISIBLE); - owner.setText(R.string.owner); } else { - boolean isPoster = TextUtils.equals(commentModel.getUser().getLogin(), poster); - if (isPoster) { + boolean isRepoOwner = TextUtils.equals(commentModel.getUser().getLogin(), repoOwner); + if (isRepoOwner) { owner.setVisibility(View.VISIBLE); - owner.setText(R.string.original_poster); + owner.setText(R.string.owner); } else { - owner.setText(null); - owner.setVisibility(View.GONE); + boolean isPoster = TextUtils.equals(commentModel.getUser().getLogin(), poster); + if (isPoster) { + owner.setVisibility(View.VISIBLE); + owner.setText(R.string.original_poster); + } else { + owner.setText(null); + owner.setVisibility(View.GONE); + } } } } diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewsViewHolder.kt b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewsViewHolder.kt index 79d18243..8afc043b 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewsViewHolder.kt +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/ReviewsViewHolder.kt @@ -21,7 +21,8 @@ import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder class ReviewsViewHolder private constructor(itemView: View, adapter: BaseRecyclerAdapter<*, *, *>?, - val viewGroup: ViewGroup) : BaseViewHolder(itemView, adapter) { + val viewGroup: ViewGroup) + : BaseViewHolder(itemView, adapter) { @BindView(R.id.stateImage) lateinit var stateImage: ForegroundImageView @BindView(R.id.avatarLayout) lateinit var avatarLayout: AvatarLayout @@ -42,7 +43,8 @@ class ReviewsViewHolder private constructor(itemView: View, it.user.login } else { "" - }).append(" ${if (model.event == IssueEventType.reviewed) "reviewed" else "requested changes"} ").append(ParseDateFormat.getTimeAgo(it.submittedAt)) + }).append(" ${if (model.event == IssueEventType.reviewed) "reviewed" else "requested changes"} ") + .append(ParseDateFormat.getTimeAgo(it.submittedAt)) if (!it.bodyHtml.isNullOrBlank()) { HtmlHelper.htmlIntoTextView(body, it.bodyHtml, viewGroup.width) body.visibility = View.VISIBLE diff --git a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/TimelineCommentsViewHolder.java b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/TimelineCommentsViewHolder.java index bf92290e..920fa1e3 100644 --- a/app/src/main/java/com/fastaccess/ui/adapter/viewholder/TimelineCommentsViewHolder.java +++ b/app/src/main/java/com/fastaccess/ui/adapter/viewholder/TimelineCommentsViewHolder.java @@ -120,18 +120,23 @@ public class TimelineCommentsViewHolder extends BaseViewHolder { avatar.setUrl(commentsModel.getUser().getAvatarUrl(), commentsModel.getUser().getLogin(), false, LinkParserHelper.isEnterprise(commentsModel.getHtmlUrl())); name.setText(commentsModel.getUser() != null ? commentsModel.getUser().getLogin() : "Anonymous"); - boolean isRepoOwner = TextUtils.equals(commentsModel.getUser().getLogin(), repoOwner); - if (isRepoOwner) { + if (commentsModel.getAuthorAssociation() != null && !"none".equalsIgnoreCase(commentsModel.getAuthorAssociation())) { + owner.setText(commentsModel.getAuthorAssociation().toLowerCase()); owner.setVisibility(View.VISIBLE); - owner.setText(R.string.owner); } else { - boolean isPoster = TextUtils.equals(commentsModel.getUser().getLogin(), poster); - if (isPoster) { + boolean isRepoOwner = TextUtils.equals(commentsModel.getUser().getLogin(), repoOwner); + if (isRepoOwner) { owner.setVisibility(View.VISIBLE); - owner.setText(R.string.original_poster); + owner.setText(R.string.owner); } else { - owner.setText(null); - owner.setVisibility(View.GONE); + boolean isPoster = TextUtils.equals(commentsModel.getUser().getLogin(), poster); + if (isPoster) { + owner.setVisibility(View.VISIBLE); + owner.setText(R.string.original_poster); + } else { + owner.setText(null); + owner.setVisibility(View.GONE); + } } } } else { diff --git a/app/src/main/java/com/fastaccess/ui/base/BaseDialogFragment.java b/app/src/main/java/com/fastaccess/ui/base/BaseDialogFragment.java index 59c28dd2..ed7db43e 100644 --- a/app/src/main/java/com/fastaccess/ui/base/BaseDialogFragment.java +++ b/app/src/main/java/com/fastaccess/ui/base/BaseDialogFragment.java @@ -19,6 +19,7 @@ import com.evernote.android.state.StateSaver; import com.fastaccess.R; import com.fastaccess.helper.AnimHelper; import com.fastaccess.helper.AppHelper; +import com.fastaccess.helper.PrefGetter; import com.fastaccess.ui.base.mvp.BaseMvp; import com.fastaccess.ui.base.mvp.presenter.BasePresenter; @@ -70,12 +71,17 @@ public abstract class BaseDialogFragment AnimHelper.revealDialog(dialog, - getResources().getInteger(android.R.integer.config_longAnimTime))); + if (!PrefGetter.isAppAnimationDisabled()) { + dialog.setOnShowListener(dialogInterface -> AnimHelper.revealDialog(dialog, + getResources().getInteger(android.R.integer.config_longAnimTime))); + } return dialog; } @@ -134,10 +142,6 @@ public abstract class BaseDialogFragment, private val extraNav: Navigati item.itemId == R.id.orgs -> view.onOpenOrgsDialog() item.itemId == R.id.notifications -> view.startActivity(Intent(view, NotificationActivity::class.java)) item.itemId == R.id.trending -> view.startActivity(Intent(view, TrendingActivity::class.java)) + item.itemId == R.id.reportBug -> view.startActivity(CreateIssueActivity.startForResult(view)) } } }, 250) diff --git a/app/src/main/java/com/fastaccess/ui/modules/about/FastHubAboutActivity.java b/app/src/main/java/com/fastaccess/ui/modules/about/FastHubAboutActivity.java index 1de10fde..9b4f7fdf 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/about/FastHubAboutActivity.java +++ b/app/src/main/java/com/fastaccess/ui/modules/about/FastHubAboutActivity.java @@ -146,7 +146,7 @@ public class FastHubAboutActivity extends MaterialAboutActivity { .addItem(new MaterialAboutActionItem.Builder() .text(R.string.join_slack) .icon(ContextCompat.getDrawable(context, R.drawable.ic_slack)) - .setOnClickAction(() -> ActivityHelper.startCustomTab(this, "http://rebrand.ly/fasthub-slack")) + .setOnClickAction(() -> ActivityHelper.startCustomTab(this, "http://rebrand.ly/fasthub")) .build()) .addItem(new MaterialAboutActionItem.Builder() .text(R.string.open_source_libs) diff --git a/app/src/main/java/com/fastaccess/ui/modules/code/CodeViewerActivity.java b/app/src/main/java/com/fastaccess/ui/modules/code/CodeViewerActivity.java index c7b96073..218a1741 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/code/CodeViewerActivity.java +++ b/app/src/main/java/com/fastaccess/ui/modules/code/CodeViewerActivity.java @@ -102,7 +102,13 @@ public class CodeViewerActivity extends BaseActivity { @Override public boolean onOptionsItemSelected(MenuItem item) { if (InputHelper.isEmpty(url)) return super.onOptionsItemSelected(item); - if (item.getItemId() == R.id.download) { + if (item.getItemId() == R.id.viewAsCode) { + ViewerFragment viewerFragment = (ViewerFragment) AppHelper.getFragmentByTag(getSupportFragmentManager(), ViewerFragment.TAG); + if (viewerFragment != null) { + viewerFragment.onViewAsCode(); + } + return true; + } else if (item.getItemId() == R.id.download) { if (ActivityHelper.checkAndRequestReadWritePermission(this)) { RestProvider.downloadFile(this, url); } diff --git a/app/src/main/java/com/fastaccess/ui/modules/editor/EditorActivity.kt b/app/src/main/java/com/fastaccess/ui/modules/editor/EditorActivity.kt index dd300dc2..d3cec953 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/editor/EditorActivity.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/editor/EditorActivity.kt @@ -23,10 +23,10 @@ import com.fastaccess.data.dao.EditReviewCommentModel import com.fastaccess.data.dao.model.Comment import com.fastaccess.helper.* import com.fastaccess.provider.emoji.Emoji -import com.fastaccess.provider.markdown.CachedComments import com.fastaccess.provider.markdown.MarkDownProvider import com.fastaccess.ui.base.BaseActivity import com.fastaccess.ui.widgets.FontTextView +import com.fastaccess.ui.widgets.dialog.MessageDialogView import com.fastaccess.ui.widgets.markdown.MarkDownLayout import com.fastaccess.ui.widgets.markdown.MarkdownEditText import java.util.* @@ -51,21 +51,13 @@ class EditorActivity : BaseActivity(), EditorMv @BindView(R.id.parentView) lateinit var parentView: View @BindView(R.id.autocomplete) lateinit var mention: ListView - @State - @BundleConstant.ExtraType - var extraType: String? = null - @State - var itemId: String? = null - @State - var login: String? = null - @State - var issueNumber: Int = 0 - @State - var commentId: Long = 0 - @State - var sha: String? = null - @State - var reviewComment: EditReviewCommentModel? = null + @State @BundleConstant.ExtraType var extraType: String? = null + @State var itemId: String? = null + @State var login: String? = null + @State var issueNumber: Int = 0 + @State var commentId: Long = 0 + @State var sha: String? = null + @State var reviewComment: EditReviewCommentModel? = null override fun layout(): Int = R.layout.editor_layout @@ -172,6 +164,15 @@ class EditorActivity : BaseActivity(), EditorMv override fun onBackPressed() { if (!InputHelper.isEmpty(editText)) { ViewHelper.hideKeyboard(editText) + MessageDialogView.newInstance(getString(R.string.close), getString(R.string.unsaved_data_warning), + Bundler.start() + .put("primary_extra", getString(R.string.discard)) + .put("secondary_extra", getString(R.string.cancel)) + .put(BundleConstant.EXTRA, true) + .end()) + .show(supportFragmentManager, MessageDialogView.TAG) + return + } super.onBackPressed() } @@ -184,12 +185,7 @@ class EditorActivity : BaseActivity(), EditorMv } override fun onAppendLink(title: String?, link: String?, isLink: Boolean) { - if (isLink) { - MarkDownProvider.addLink(editText, InputHelper.toString(title), InputHelper.toString(link)) - } else { - editText.setText(String.format("%s\n", editText.text)) - MarkDownProvider.addPhoto(editText, InputHelper.toString(title), InputHelper.toString(link)) - } + markDownLayout.onAppendLink(title, link, isLink) } override fun getEditText(): EditText = editText @@ -220,12 +216,12 @@ class EditorActivity : BaseActivity(), EditorMv commentId = bundle.getLong(BundleConstant.EXTRA_FOUR) val textToUpdate = bundle.getString(BundleConstant.EXTRA) if (!InputHelper.isEmpty(textToUpdate)) { - editText.setText(String.format("%s ", textToUpdate)) + editText.setText(String.format("%s", textToUpdate)) editText.setSelection(InputHelper.toString(editText).length) } - if (bundle.getString("message", "").isEmpty()) + if (bundle.getString("message", "").isBlank()) { replyQuote.visibility = GONE - else { + } else { MarkDownProvider.setMdText(quote, bundle.getString("message", "")) } participants = bundle.getStringArrayList("participants") diff --git a/app/src/main/java/com/fastaccess/ui/modules/editor/EditorPresenter.kt b/app/src/main/java/com/fastaccess/ui/modules/editor/EditorPresenter.kt index 149dd7e2..df35994b 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/editor/EditorPresenter.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/editor/EditorPresenter.kt @@ -93,7 +93,7 @@ class EditorPresenter : BasePresenter(), EditorMvp.Presenter { } private fun onEditReviewComment(reviewComment: EditReviewCommentModel, savedText: CharSequence, repoId: String, - login: String, issueNumber: Int, id: Long) { + login: String, @Suppress("UNUSED_PARAMETER") issueNumber: Int, id: Long) { if (!InputHelper.isEmpty(savedText)) { val requestModel = CommentRequestModel() requestModel.body = savedText.toString() diff --git a/app/src/main/java/com/fastaccess/ui/modules/editor/comment/CommentEditorFragment.kt b/app/src/main/java/com/fastaccess/ui/modules/editor/comment/CommentEditorFragment.kt index 5d794f69..6d7996ee 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/editor/comment/CommentEditorFragment.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/editor/comment/CommentEditorFragment.kt @@ -18,30 +18,36 @@ import com.fastaccess.helper.Bundler import com.fastaccess.helper.InputHelper import com.fastaccess.helper.ViewHelper import com.fastaccess.provider.emoji.Emoji +import com.fastaccess.provider.timeline.CommentsHelper import com.fastaccess.ui.base.BaseFragment import com.fastaccess.ui.base.mvp.BaseMvp import com.fastaccess.ui.base.mvp.presenter.BasePresenter import com.fastaccess.ui.modules.editor.EditorActivity import com.fastaccess.ui.modules.editor.emoji.EmojiMvp +import com.fastaccess.ui.modules.editor.popup.EditorLinkImageMvp import com.fastaccess.ui.widgets.markdown.MarkDownLayout import com.fastaccess.ui.widgets.markdown.MarkdownEditText +import net.yslibrary.android.keyboardvisibilityevent.KeyboardVisibilityEvent +import net.yslibrary.android.keyboardvisibilityevent.Unregistrar /** * Created by kosh on 21/08/2017. */ class CommentEditorFragment : BaseFragment>(), MarkDownLayout.MarkdownListener, - EmojiMvp.EmojiCallback { + EmojiMvp.EmojiCallback, EditorLinkImageMvp.EditorLinkCallback { @BindView(R.id.commentBox) lateinit var commentBox: View @BindView(R.id.markdDownLayout) lateinit var markdDownLayout: MarkDownLayout @BindView(R.id.commentText) lateinit var commentText: MarkdownEditText @BindView(R.id.markdownBtnHolder) lateinit var markdownBtnHolder: View + @BindView(R.id.sendComment) lateinit var sendComment: View + @BindView(R.id.toggleButtons) lateinit var toggleButtons: View private var commentListener: CommentListener? = null + private var keyboardListener: Unregistrar? = null @OnClick(R.id.sendComment) internal fun onComment() { if (!InputHelper.isEmpty(getEditText())) { commentListener?.onSendActionClicked(InputHelper.toString(getEditText()), arguments?.getBundle(BundleConstant.ITEM)) - getEditText().setText("") ViewHelper.hideKeyboard(getEditText()) arguments = null } @@ -52,6 +58,7 @@ class CommentEditorFragment : BaseFragment? + } + companion object { fun newInstance(bundle: Bundle?): CommentEditorFragment { val fragment = CommentEditorFragment() diff --git a/app/src/main/java/com/fastaccess/ui/modules/editor/popup/EditorLinkImageDialogFragment.java b/app/src/main/java/com/fastaccess/ui/modules/editor/popup/EditorLinkImageDialogFragment.java index b37ea4f4..0d0549ad 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/editor/popup/EditorLinkImageDialogFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/editor/popup/EditorLinkImageDialogFragment.java @@ -49,7 +49,9 @@ public class EditorLinkImageDialogFragment extends BaseDialogFragment view.onUploaded(null, null)); - }); + }, false); } else { if (getView() != null) getView().onUploaded(null, null); } diff --git a/app/src/main/java/com/fastaccess/ui/modules/feeds/FeedsPresenter.java b/app/src/main/java/com/fastaccess/ui/modules/feeds/FeedsPresenter.java index 6578a7f3..085990ae 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/feeds/FeedsPresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/feeds/FeedsPresenter.java @@ -185,7 +185,11 @@ public class FeedsPresenter extends BasePresenter implements Feed .collect(Collectors.toCollection(ArrayList::new))); } } else { - onItemClick(position, v, item); + Repo repo = item.getRepo(); + if (repo != null) { + NameParser parser = new NameParser(repo.getUrl()); + RepoPagerActivity.startRepoPager(v.getContext(), parser); + } } } } diff --git a/app/src/main/java/com/fastaccess/ui/modules/filter/issues/fragment/FilterIssueFragment.java b/app/src/main/java/com/fastaccess/ui/modules/filter/issues/fragment/FilterIssueFragment.java index 3881656e..dbbac513 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/filter/issues/fragment/FilterIssueFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/filter/issues/fragment/FilterIssueFragment.java @@ -10,17 +10,15 @@ import android.view.View; import com.evernote.android.state.State; import com.fastaccess.R; -import com.fastaccess.data.dao.PullsIssuesParser; import com.fastaccess.data.dao.model.Issue; import com.fastaccess.data.dao.types.IssueState; import com.fastaccess.helper.InputHelper; import com.fastaccess.provider.rest.loadmore.OnLoadMore; +import com.fastaccess.provider.scheme.SchemeParser; import com.fastaccess.ui.adapter.IssuesAdapter; import com.fastaccess.ui.base.BaseFragment; import com.fastaccess.ui.modules.filter.issues.FilterIssuesActivityMvp; import com.fastaccess.ui.modules.repos.extras.popup.IssuePopupFragment; -import com.fastaccess.ui.modules.repos.issues.issue.details.IssuePagerActivity; -import com.fastaccess.ui.modules.repos.pull_requests.pull_request.details.PullRequestPagerActivity; import com.fastaccess.ui.widgets.StateLayout; import com.fastaccess.ui.widgets.recyclerview.DynamicRecyclerView; import com.fastaccess.ui.widgets.recyclerview.scroll.RecyclerViewFastScroller; @@ -132,19 +130,7 @@ public class FilterIssueFragment extends BaseFragment } @Override public void onSendActionClicked(@NonNull String text, Bundle bundle) { - if (pager == null || pager.getAdapter() == null) return; - GistCommentsFragment view = (GistCommentsFragment) pager.getAdapter().instantiateItem(pager, 1); + GistCommentsFragment view = getGistCommentsFragment(); if (view != null) { view.onHandleComment(text, bundle); } @@ -278,6 +279,24 @@ public class GistActivity extends BaseActivity commentEditorFragment.onAddUserName(username); } + @Override public void onCreateComment(String text, Bundle bundle) { + + } + + @SuppressWarnings("ConstantConditions") @Override public void onClearEditText() { + if (commentEditorFragment != null && commentEditorFragment.commentText != null) commentEditorFragment.commentText.setText(null); + } + + @NonNull @Override public ArrayList getNamesToTag() { + GistCommentsFragment view = getGistCommentsFragment(); + if (view != null) return view.getNamesToTag(); + return new ArrayList<>(); + } + + @Nullable private GistCommentsFragment getGistCommentsFragment() { + if (pager == null || pager.getAdapter() == null) return null; + return (GistCommentsFragment) pager.getAdapter().instantiateItem(pager, 1); + } private void hideShowFab() { if (pager.getCurrentItem() == 1) { @@ -286,8 +305,4 @@ public class GistActivity extends BaseActivity getSupportFragmentManager().beginTransaction().hide(commentEditorFragment).commit(); } } - - @Override public void onCreateComment(String text, Bundle bundle) { - - } } diff --git a/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsFragment.java b/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsFragment.java index 64870fa7..a520df8b 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsFragment.java @@ -29,6 +29,7 @@ import com.fastaccess.ui.widgets.dialog.MessageDialogView; import com.fastaccess.ui.widgets.recyclerview.DynamicRecyclerView; import com.fastaccess.ui.widgets.recyclerview.scroll.RecyclerViewFastScroller; +import java.util.ArrayList; import java.util.List; import butterknife.BindView; @@ -199,6 +200,11 @@ public class GistCommentsFragment extends BaseFragment getNamesToTag() { + return CommentsHelper.getUsers(adapter.getData()); } @Override public void onDestroyView() { diff --git a/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsMvp.java b/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsMvp.java index c3cee504..281f2399 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsMvp.java +++ b/app/src/main/java/com/fastaccess/ui/modules/gists/gist/comments/GistCommentsMvp.java @@ -40,6 +40,8 @@ interface GistCommentsMvp { void onHandleComment(@NonNull String text, @Nullable Bundle bundle); void onAddNewComment(@NonNull Comment comment); + + @NonNull ArrayList getNamesToTag(); } interface Presenter extends BaseMvp.FAPresenter, diff --git a/app/src/main/java/com/fastaccess/ui/modules/main/MainActivity.java b/app/src/main/java/com/fastaccess/ui/modules/main/MainActivity.java index db12f7c2..e3b7f20e 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/main/MainActivity.java +++ b/app/src/main/java/com/fastaccess/ui/modules/main/MainActivity.java @@ -9,8 +9,10 @@ import android.support.design.widget.FloatingActionButton; import android.support.v4.view.GravityCompat; import android.view.Menu; import android.view.MenuItem; +import android.widget.Toast; import com.evernote.android.state.State; +import com.fastaccess.App; import com.fastaccess.R; import com.fastaccess.data.dao.model.Login; import com.fastaccess.data.dao.model.Notification; @@ -109,8 +111,8 @@ public class MainActivity extends BaseActivity impl if (isLoggedIn() && Notification.hasUnreadNotifications()) { ViewHelper.tintDrawable(menu.findItem(R.id.notifications).setIcon(R.drawable.ic_ring).getIcon(), ViewHelper.getAccentColor(this)); } else { - ViewHelper.tintDrawable(menu.findItem(R.id.notifications).setIcon(R.drawable.ic_notifications_none).getIcon(), ViewHelper.getIconColor - (this)); + ViewHelper.tintDrawable(menu.findItem(R.id.notifications) + .setIcon(R.drawable.ic_notifications_none).getIcon(), ViewHelper.getIconColor(this)); } return super.onPrepareOptionsMenu(menu); } @@ -140,6 +142,11 @@ public class MainActivity extends BaseActivity impl invalidateOptionsMenu(); } + @Override public void onUserIsBlackListed() { + Toast.makeText(App.getInstance(), "You are blacklisted, please contact the dev", Toast.LENGTH_LONG).show(); + finish(); + } + @Shortcut(id = "myIssues", icon = R.drawable.ic_app_shortcut_issues, shortLabelRes = R.string.issues, rank = 2, action = "myIssues") public void myIssues() {}//do nothing diff --git a/app/src/main/java/com/fastaccess/ui/modules/main/MainMvp.java b/app/src/main/java/com/fastaccess/ui/modules/main/MainMvp.java index f9be7274..50b04eea 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/main/MainMvp.java +++ b/app/src/main/java/com/fastaccess/ui/modules/main/MainMvp.java @@ -41,6 +41,8 @@ public interface MainMvp { void onOpenProfile(); void onInvalidateNotification(); + + void onUserIsBlackListed(); } interface Presenter extends BaseMvp.FAPresenter, diff --git a/app/src/main/java/com/fastaccess/ui/modules/main/MainPresenter.java b/app/src/main/java/com/fastaccess/ui/modules/main/MainPresenter.java index 31a86374..5dd36dc4 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/main/MainPresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/main/MainPresenter.java @@ -10,6 +10,7 @@ import android.support.v4.widget.DrawerLayout; import com.fastaccess.R; import com.fastaccess.data.dao.model.Login; import com.fastaccess.data.dao.model.Notification; +import com.fastaccess.helper.Logger; import com.fastaccess.helper.ParseDateFormat; import com.fastaccess.helper.PrefGetter; import com.fastaccess.helper.RxHelper; @@ -18,6 +19,12 @@ import com.fastaccess.ui.base.mvp.presenter.BasePresenter; import com.fastaccess.ui.modules.feeds.FeedsFragment; import com.fastaccess.ui.modules.main.issues.pager.MyIssuesPagerFragment; import com.fastaccess.ui.modules.main.pullrequests.pager.MyPullsPagerFragment; +import com.github.b3er.rxfirebase.database.RxFirebaseDatabase; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.GenericTypeIndicator; + +import java.util.ArrayList; +import java.util.List; import io.reactivex.Single; @@ -31,6 +38,7 @@ import static com.fastaccess.helper.AppHelper.getFragmentByTag; public class MainPresenter extends BasePresenter implements MainMvp.Presenter { MainPresenter() { + checkBlackListed(); setEnterprise(PrefGetter.isEnterprise()); manageDisposable(RxHelper.getObservable(RestProvider.getUserService(isEnterprise()).getUser()) .flatMap(login -> { @@ -47,8 +55,10 @@ public class MainPresenter extends BasePresenter implements MainMv .flatMap(login -> RxHelper.getObservable(RestProvider.getNotificationService(isEnterprise()) .getNotifications(ParseDateFormat.getLastWeekDate()))) .flatMapSingle(notificationPageable -> { - if (notificationPageable != null) { + if (notificationPageable != null && (notificationPageable.getItems() != null && !notificationPageable.getItems().isEmpty())) { return Notification.saveAsSingle(notificationPageable.getItems()); + } else { + Notification.deleteAll(); } return Single.just(true); }) @@ -131,4 +141,28 @@ public class MainPresenter extends BasePresenter implements MainMv } @Override public void onMenuItemReselect(@IdRes int id, int position, boolean fromUser) {} + + private void checkBlackListed() { + manageDisposable(RxHelper.getSingle(RxFirebaseDatabase + .data(FirebaseDatabase.getInstance().getReference().child("black_listed"))) + .map(dataSnapshot -> { + boolean exists = false; + Login login = Login.getUser(); + Logger.e(dataSnapshot); + if (login != null) { + if (dataSnapshot != null && dataSnapshot.exists()) { + List values = dataSnapshot.getValue(new GenericTypeIndicator>() {}); + if (values != null && !values.isEmpty()) { + exists = values.contains(Login.getUser().getLogin()); + } + } + } + return exists; + }) + .subscribe(exists -> { + if (exists) { + sendToView(MainMvp.View::onUserIsBlackListed); + } + }, Throwable::printStackTrace)); + } } diff --git a/app/src/main/java/com/fastaccess/ui/modules/main/premium/PremiumActivity.kt b/app/src/main/java/com/fastaccess/ui/modules/main/premium/PremiumActivity.kt index 1320e6f1..86cee4f2 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/main/premium/PremiumActivity.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/main/premium/PremiumActivity.kt @@ -1,5 +1,6 @@ package com.fastaccess.ui.modules.main.premium +import android.animation.Animator import android.app.Activity import android.content.Context import android.content.Intent @@ -10,6 +11,7 @@ import android.widget.FrameLayout import butterknife.BindView import butterknife.OnClick import butterknife.OnEditorAction +import com.airbnb.lottie.LottieAnimationView import com.fastaccess.BuildConfig import com.fastaccess.R import com.fastaccess.helper.AppHelper @@ -28,6 +30,8 @@ class PremiumActivity : BaseActivity(), Premi @BindView(R.id.editText) lateinit var editText: EditText @BindView(R.id.viewGroup) lateinit var viewGroup: FrameLayout @BindView(R.id.progressLayout) lateinit var progressLayout: View + @BindView(R.id.successActivationView) lateinit var successActivationView: LottieAnimationView + @BindView(R.id.successActivationHolder) lateinit var successActivationHolder: View override fun layout(): Int = R.layout.pro_features_layout @@ -87,11 +91,23 @@ class PremiumActivity : BaseActivity(), Premi } override fun onSuccessfullyActivated() { - FabricProvider.logPurchase(InputHelper.toString(editText)) - PrefGetter.setProItems() - PrefGetter.setEnterpriseItem() - showMessage(R.string.success, R.string.success) - successResult() + hideProgress() + successActivationHolder.visibility = View.VISIBLE + successActivationView.addAnimatorListener(object : Animator.AnimatorListener { + override fun onAnimationRepeat(p0: Animator?) {} + override fun onAnimationEnd(p0: Animator?) { + FabricProvider.logPurchase(InputHelper.toString(editText)) + PrefGetter.setProItems() + PrefGetter.setEnterpriseItem() + showMessage(R.string.success, R.string.success) + successResult() + } + + override fun onAnimationCancel(p0: Animator?) {} + + override fun onAnimationStart(p0: Animator?) {} + }) + successActivationView.playAnimation() } override fun onNoMatch() { diff --git a/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewFragment.java b/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewFragment.java index ccd9e760..15a531f6 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewFragment.java @@ -40,6 +40,7 @@ import com.fastaccess.ui.adapter.ProfilePinnedReposAdapter; import com.fastaccess.ui.base.BaseFragment; import com.fastaccess.ui.modules.profile.ProfilePagerMvp; import com.fastaccess.ui.widgets.AvatarLayout; +import com.fastaccess.ui.widgets.FontButton; import com.fastaccess.ui.widgets.FontTextView; import com.fastaccess.ui.widgets.SpannableBuilder; import com.fastaccess.ui.widgets.contributions.GitHubContributionsView; @@ -51,7 +52,7 @@ import java.util.List; import butterknife.BindView; import butterknife.OnClick; -import pr.GetPinnedReposQuery; +import github.GetPinnedReposQuery; import static android.view.Gravity.TOP; import static android.view.View.GONE; @@ -78,8 +79,8 @@ public class ProfileOverviewFragment extends BaseFragment nodes) { + if (pinnedReposTextView == null) return; if (!nodes.isEmpty()) { pinnedReposTextView.setVisibility(VISIBLE); pinnedReposCard.setVisibility(VISIBLE); diff --git a/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewMvp.java b/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewMvp.java index 0e271ff5..a3117244 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewMvp.java +++ b/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewMvp.java @@ -13,7 +13,7 @@ import com.fastaccess.ui.widgets.contributions.GitHubContributionsView; import java.util.ArrayList; import java.util.List; -import pr.GetPinnedReposQuery; +import github.GetPinnedReposQuery; /** * Created by Kosh on 03 Dec 2016, 9:15 AM diff --git a/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewPresenter.java b/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewPresenter.java index fcc43eba..55f91001 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewPresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/profile/overview/ProfileOverviewPresenter.java @@ -8,12 +8,12 @@ import android.text.TextUtils; import com.apollographql.apollo.ApolloCall; import com.apollographql.apollo.rx2.Rx2Apollo; -import com.fastaccess.App; import com.fastaccess.data.dao.model.Login; import com.fastaccess.data.dao.model.User; import com.fastaccess.helper.BundleConstant; import com.fastaccess.helper.InputHelper; import com.fastaccess.helper.RxHelper; +import com.fastaccess.provider.rest.ApolloProdivder; import com.fastaccess.provider.rest.RestProvider; import com.fastaccess.ui.base.mvp.presenter.BasePresenter; import com.fastaccess.ui.widgets.contributions.ContributionsDay; @@ -23,8 +23,8 @@ import com.fastaccess.ui.widgets.contributions.GitHubContributionsView; import java.util.ArrayList; import java.util.List; +import github.GetPinnedReposQuery; import io.reactivex.Observable; -import pr.GetPinnedReposQuery; /** * Created by Kosh on 03 Dec 2016, 9:16 AM @@ -106,11 +106,11 @@ class ProfileOverviewPresenter extends BasePresenter im } @SuppressWarnings("ConstantConditions") private void loadPinnedRepos(@NonNull String login) { - ApolloCall apolloCall = App.getInstance().getApolloClient() + ApolloCall apolloCall = ApolloProdivder.INSTANCE.getApollo(isEnterprise()) .query(GetPinnedReposQuery.builder() .login(login) .build()); - manageObservable(Rx2Apollo.from(apolloCall) + manageDisposable(RxHelper.getObservable(Rx2Apollo.from(apolloCall)) .filter(dataResponse -> !dataResponse.hasErrors()) .flatMap(dataResponse -> { if (dataResponse.data() != null && dataResponse.data().user() != null) { @@ -121,11 +121,11 @@ class ProfileOverviewPresenter extends BasePresenter im .map(GetPinnedReposQuery.Edge::node) .toList() .toObservable() - .doOnNext(nodes1 -> { + .subscribe(nodes1 -> { nodes.clear(); nodes.addAll(nodes1); sendToView(view -> view.onInitPinnedRepos(nodes)); - })); + }, Throwable::printStackTrace)); } @Override public void onWorkOffline(@NonNull String login) { diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/RepoPagerActivity.java b/app/src/main/java/com/fastaccess/ui/modules/repos/RepoPagerActivity.java index 40b4e50e..2e20a815 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/RepoPagerActivity.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/RepoPagerActivity.java @@ -39,6 +39,7 @@ import com.fastaccess.helper.TypeFaceHelper; import com.fastaccess.helper.ViewHelper; import com.fastaccess.provider.colors.ColorsProvider; import com.fastaccess.provider.scheme.LinkParserHelper; +import com.fastaccess.provider.scheme.SchemeParser; import com.fastaccess.provider.tasks.git.GithubActionService; import com.fastaccess.ui.adapter.TopicsAdapter; import com.fastaccess.ui.base.BaseActivity; @@ -102,6 +103,7 @@ public class RepoPagerActivity extends BaseActivity implements Rep AppHelper.getFragmentByTag(fragmentManager, RepoIssuesPagerFragment.TAG); RepoPullRequestPagerFragment pullRequestPagerView = (RepoPullRequestPagerFragment) AppHelper.getFragmentByTag(fragmentManager, RepoPullRequestPagerFragment.TAG); + RepoProjectsFragmentPager projectsFragmentPager = (RepoProjectsFragmentPager) AppHelper.getFragmentByTag(fragmentManager, + RepoProjectsFragmentPager.Companion.getTAG()); if (getRepo() == null) { sendToView(RepoPagerMvp.View::onFinishActivity); return; @@ -219,6 +222,13 @@ class RepoPagerPresenter extends BasePresenter implements Rep onShowHideFragment(fragmentManager, pullRequestPagerView, currentVisible); } break; + case RepoPagerMvp.PROJECTS: + if (projectsFragmentPager == null) { + onAddAndHide(fragmentManager, RepoProjectsFragmentPager.Companion.newInstance(repoId(), login()), currentVisible); + } else { + onShowHideFragment(fragmentManager, projectsFragmentPager, currentVisible); + } + break; } } diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/RepoCommitsFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/RepoCommitsFragment.java index 70698d0c..1eb82bdd 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/RepoCommitsFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/RepoCommitsFragment.java @@ -43,12 +43,22 @@ public class RepoCommitsFragment extends BaseFragment implements @com.evernote.android.state.State String login; @com.evernote.android.state.State String repoId; @com.evernote.android.state.State String branch; + @com.evernote.android.state.State String path; private int page; private int previousTotal; private int lastPage = Integer.MAX_VALUE; @@ -63,16 +67,18 @@ class RepoCommitsPresenter extends BasePresenter implements return false; } if (repoId == null || login == null) return false; - makeRestCall(RestProvider.getRepoService(isEnterprise()).getCommits(login, repoId, branch, page), - response -> { - if (response != null && response.getItems() != null) { - lastPage = response.getLast(); - if (getCurrentPage() == 1) { - manageDisposable(Commit.save(response.getItems(), repoId, login)); - } - } - sendToView(view -> view.onNotifyAdapter(response != null ? response.getItems() : null, page)); - }); + Observable> observable = InputHelper.isEmpty(path) + ? RestProvider.getRepoService(isEnterprise()).getCommits(login, repoId, branch, page) + : RestProvider.getRepoService(isEnterprise()).getCommits(login, repoId, branch, path, page); + makeRestCall(observable, response -> { + if (response != null && response.getItems() != null) { + lastPage = response.getLast(); + if (getCurrentPage() == 1) { + manageDisposable(Commit.save(response.getItems(), repoId, login)); + } + } + sendToView(view -> view.onNotifyAdapter(response != null ? response.getItems() : null, page)); + }); return true; } @@ -80,6 +86,7 @@ class RepoCommitsPresenter extends BasePresenter implements repoId = bundle.getString(BundleConstant.ID); login = bundle.getString(BundleConstant.EXTRA); branch = bundle.getString(BundleConstant.EXTRA_TWO); + path = bundle.getString(BundleConstant.EXTRA_THREE); if (!InputHelper.isEmpty(branch)) { getCommitCount(branch); } diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/CommitPagerActivity.java b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/CommitPagerActivity.java index 0110f162..699d4c48 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/CommitPagerActivity.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/CommitPagerActivity.java @@ -40,6 +40,7 @@ import com.fastaccess.ui.widgets.SpannableBuilder; import com.fastaccess.ui.widgets.ViewPagerView; import com.fastaccess.ui.widgets.dialog.MessageDialogView; +import java.util.ArrayList; import java.util.Date; import butterknife.BindView; @@ -137,6 +138,7 @@ public class CommitPagerActivity extends BaseActivity getNamesToTag() { + CommitCommentsFragment fragment = getCommitCommentsFragment(); + if (fragment != null) { + return fragment.getNamesToTags(); + } + return new ArrayList<>(); + } + private void hideShowFab() { if (pager.getCurrentItem() == 1) { getSupportFragmentManager().beginTransaction().show(commentEditorFragment).commit(); @@ -265,7 +279,9 @@ public class CommitPagerActivity extends BaseActivity getNamesToTags() { + return CommentsHelper.getUsersByTimeline(adapter.getData()); + } } diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsMvp.java b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsMvp.java index 029d6f8f..36351979 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsMvp.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsMvp.java @@ -48,6 +48,8 @@ interface CommitCommentsMvp { void showReload(); void onHandleComment(@NonNull String text, @Nullable Bundle bundle); + + @NonNull List getNamesToTags(); } interface Presenter extends BaseMvp.FAPresenter, diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsPresenter.java b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsPresenter.java index 5d2f9edb..404cd03e 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsPresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/details/comments/CommitCommentsPresenter.java @@ -37,6 +37,7 @@ class CommitCommentsPresenter extends BasePresenter impl @com.evernote.android.state.State String repoId; @com.evernote.android.state.State String login; @com.evernote.android.state.State String sha; + @com.evernote.android.state.State boolean isCollaborator; @Override public int getCurrentPage() { @@ -64,6 +65,11 @@ class CommitCommentsPresenter extends BasePresenter impl sendToView(CommitCommentsMvp.View::hideProgress); return false; } + if (page == 1) { + manageObservable(RestProvider.getRepoService(isEnterprise()).isCollaborator(login, repoId, + Login.getUser().getLogin()) + .doOnNext(booleanResponse -> isCollaborator = booleanResponse.code() == 204)); + } setCurrentPage(page); makeRestCall(RestProvider.getRepoService(isEnterprise()).getCommitComments(login, repoId, sha, page) .flatMap(listResponse -> { @@ -152,7 +158,7 @@ class CommitCommentsPresenter extends BasePresenter impl PopupMenu popupMenu = new PopupMenu(v.getContext(), v); popupMenu.inflate(R.menu.comments_menu); String username = Login.getUser().getLogin(); - boolean isOwner = CommentsHelper.isOwner(username, login, item.getUser().getLogin()); + boolean isOwner = CommentsHelper.isOwner(username, login, item.getUser().getLogin()) || isCollaborator; popupMenu.getMenu().findItem(R.id.delete).setVisible(isOwner); popupMenu.getMenu().findItem(R.id.edit).setVisible(isOwner); popupMenu.setOnMenuItemClickListener(item1 -> { diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/history/FileCommitHistoryActivity.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/history/FileCommitHistoryActivity.kt new file mode 100644 index 00000000..4c81c543 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/commit/history/FileCommitHistoryActivity.kt @@ -0,0 +1,76 @@ +package com.fastaccess.ui.modules.repos.code.commit.history + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import com.evernote.android.state.State +import com.fastaccess.R +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Bundler +import com.fastaccess.ui.base.BaseActivity +import com.fastaccess.ui.base.mvp.BaseMvp +import com.fastaccess.ui.base.mvp.presenter.BasePresenter +import com.fastaccess.ui.modules.repos.RepoPagerActivity +import com.fastaccess.ui.modules.repos.code.commit.RepoCommitsFragment + +/** + * Created by Hashemsergani on 02/09/2017. + */ +class FileCommitHistoryActivity : BaseActivity>() { + + @State var login: String? = null + @State var repoId: String? = null + + override fun layout(): Int = R.layout.activity_fragment_layout + + override fun providePresenter(): BasePresenter = BasePresenter() + + override fun isTransparent(): Boolean = true + + override fun canBack(): Boolean = true + + override fun isSecured(): Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState == null && intent != null) { + repoId = intent.extras.getString(BundleConstant.ID) + login = intent.extras.getString(BundleConstant.EXTRA) + supportFragmentManager + .beginTransaction() + .replace(R.id.container, RepoCommitsFragment.newInstance(intent.extras!!), RepoCommitsFragment::class.java.simpleName) + .commit() + } + } + + override fun onOptionsItemSelected(item: MenuItem?): Boolean { + if (item?.itemId == android.R.id.home) { + repoId?.let { + val intent = RepoPagerActivity.createIntent(this, it, login!!) + val bundle = intent.extras + bundle.putBoolean(BundleConstant.IS_ENTERPRISE, isEnterprise) + intent.putExtras(bundle) + startActivity(intent) + finish() + } + return true + } + return super.onOptionsItemSelected(item) + } + + companion object { + fun startActivity(context: Context, login: String, repoId: String, branch: String, path: String, + enterprise: Boolean) { + val intent = Intent(context, FileCommitHistoryActivity::class.java) + intent.putExtras(Bundler.start() + .put(BundleConstant.ID, repoId) + .put(BundleConstant.EXTRA, login) + .put(BundleConstant.EXTRA_TWO, branch) + .put(BundleConstant.EXTRA_THREE, path) + .put(BundleConstant.IS_ENTERPRISE, enterprise) + .end()) + context.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesFragment.java index c3bdc61f..e82d2292 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesFragment.java @@ -1,5 +1,7 @@ package com.fastaccess.ui.modules.repos.code.files; +import android.app.Activity; +import android.content.Intent; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -9,6 +11,8 @@ import android.view.View; import android.widget.PopupMenu; import com.fastaccess.R; +import com.fastaccess.data.dao.EditRepoFileModel; +import com.fastaccess.data.dao.model.Login; import com.fastaccess.data.dao.model.RepoFile; import com.fastaccess.data.dao.types.FilesType; import com.fastaccess.helper.ActivityHelper; @@ -17,13 +21,17 @@ import com.fastaccess.helper.BundleConstant; import com.fastaccess.helper.Bundler; import com.fastaccess.helper.FileHelper; import com.fastaccess.helper.InputHelper; +import com.fastaccess.helper.PrefGetter; import com.fastaccess.provider.markdown.MarkDownProvider; import com.fastaccess.provider.rest.RestProvider; import com.fastaccess.ui.adapter.RepoFilesAdapter; import com.fastaccess.ui.base.BaseFragment; import com.fastaccess.ui.modules.code.CodeViewerActivity; +import com.fastaccess.ui.modules.main.premium.PremiumActivity; import com.fastaccess.ui.modules.repos.code.files.activity.RepoFilesActivity; import com.fastaccess.ui.modules.repos.code.files.paths.RepoFilePathFragment; +import com.fastaccess.ui.modules.repos.git.EditRepoFileActivity; +import com.fastaccess.ui.modules.repos.git.delete.DeleteFileBottomSheetFragment; import com.fastaccess.ui.widgets.AppbarRefreshLayout; import com.fastaccess.ui.widgets.StateLayout; import com.fastaccess.ui.widgets.dialog.MessageDialogView; @@ -43,6 +51,7 @@ public class RepoFilesFragment extends BaseFragment { switch (item1.getItemId()) { case R.id.share: @@ -95,6 +111,25 @@ public class RepoFilesFragment extends BaseFragment getCachedFiles(@NonNull String url, @NonNull String ref); + + void onDeleteFile(@NonNull String message, @NonNull RepoFile item); } diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesPresenter.java b/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesPresenter.java index d6ce3858..a9d11eac 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesPresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/RepoFilesPresenter.java @@ -2,14 +2,17 @@ package com.fastaccess.ui.modules.repos.code.files; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.widget.SwipeRefreshLayout; import android.view.View; import com.fastaccess.R; +import com.fastaccess.data.dao.CommitRequestModel; import com.fastaccess.data.dao.RepoPathsManager; import com.fastaccess.data.dao.model.RepoFile; import com.fastaccess.helper.RxHelper; import com.fastaccess.provider.rest.RestProvider; import com.fastaccess.ui.base.mvp.presenter.BasePresenter; +import com.fastaccess.ui.modules.repos.code.commit.history.FileCommitHistoryActivity; import java.util.ArrayList; import java.util.List; @@ -33,11 +36,13 @@ class RepoFilesPresenter extends BasePresenter implements Rep if (v.getId() != R.id.menu) { getView().onItemClicked(item); } else { - getView().onMenuClicked(item, v); + getView().onMenuClicked(position, item, v); } } - @Override public void onItemLongClick(int position, View v, RepoFile item) {} + @Override public void onItemLongClick(int position, View v, RepoFile item) { + FileCommitHistoryActivity.Companion.startActivity(v.getContext(), login, repoId, ref, item.getPath(), isEnterprise()); + } @Override public void onError(@NonNull Throwable throwable) { onWorkOffline(); @@ -72,6 +77,7 @@ class RepoFilesPresenter extends BasePresenter implements Rep .flatMap(response -> { if (response != null && response.getItems() != null) { return Observable.fromIterable(response.getItems()) + .filter(repoFile -> repoFile.getType() != null) .sorted((repoFile, repoFile2) -> repoFile2.getType().compareTo(repoFile.getType())); } return Observable.empty(); @@ -114,4 +120,11 @@ class RepoFilesPresenter extends BasePresenter implements Rep @Nullable @Override public List getCachedFiles(@NonNull String url, @NonNull String ref) { return pathsModel.getPaths(url, ref); } + + @Override public void onDeleteFile(@NonNull String message, @NonNull RepoFile item) { + CommitRequestModel body = new CommitRequestModel(message, null, item.getSha()); + makeRestCall(RestProvider.getContentService(isEnterprise()) + .deleteFile(login, repoId, item.getPath(), ref, body), + gitCommitModel -> sendToView(SwipeRefreshLayout.OnRefreshListener::onRefresh)); + } } diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/paths/RepoFilePathFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/paths/RepoFilePathFragment.java index 0e3742c4..4a66270c 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/paths/RepoFilePathFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/code/files/paths/RepoFilePathFragment.java @@ -1,6 +1,8 @@ package com.fastaccess.ui.modules.repos.code.files.paths; +import android.app.Activity; import android.content.Context; +import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.support.annotation.NonNull; @@ -13,16 +15,21 @@ import com.annimon.stream.Objects; import com.evernote.android.state.State; import com.fastaccess.R; import com.fastaccess.data.dao.BranchesModel; +import com.fastaccess.data.dao.EditRepoFileModel; +import com.fastaccess.data.dao.model.Login; import com.fastaccess.data.dao.model.RepoFile; import com.fastaccess.helper.ActivityHelper; import com.fastaccess.helper.BundleConstant; import com.fastaccess.helper.Bundler; import com.fastaccess.helper.InputHelper; +import com.fastaccess.helper.PrefGetter; import com.fastaccess.provider.rest.RestProvider; import com.fastaccess.ui.adapter.RepoFilePathsAdapter; import com.fastaccess.ui.base.BaseFragment; +import com.fastaccess.ui.modules.main.premium.PremiumActivity; import com.fastaccess.ui.modules.repos.code.files.RepoFilesFragment; import com.fastaccess.ui.modules.repos.extras.branches.pager.BranchesPagerFragment; +import com.fastaccess.ui.modules.repos.git.EditRepoFileActivity; import com.fastaccess.ui.modules.search.repos.files.SearchFileActivity; import com.fastaccess.ui.widgets.FontTextView; import com.fastaccess.ui.widgets.dialog.MessageDialogView; @@ -41,6 +48,7 @@ public class RepoFilePathFragment extends BaseFragment implements ViewerMvp } } + @Override public void onLoadContentAsStream() { + boolean isImage = MarkDownProvider.isImage(url) && !"svg".equalsIgnoreCase(MimeTypeMap.getFileExtensionFromUrl(url)); + if (isImage || MarkDownProvider.isArchive(url)) { + return; + } + makeRestCall(RestProvider.getRepoService(isEnterprise()).getFileAsStream(url), + body -> { + downloadedStream = body; + sendToView(view -> view.onSetCode(body)); + }); + } + @Override public String downloadedStream() { return downloadedStream; } @@ -83,7 +96,7 @@ class ViewerPresenter extends BasePresenter implements ViewerMvp if (fileModel != null) { isImage = MarkDownProvider.isImage(fileModel.getFullUrl()); if (isImage) { - sendToView(view -> view.onSetImageUrl(fileModel.getFullUrl())); + sendToView(view -> view.onSetImageUrl(fileModel.getFullUrl(), false)); } else { downloadedStream = fileModel.getContent(); isRepo = fileModel.isRepo(); @@ -104,7 +117,12 @@ class ViewerPresenter extends BasePresenter implements ViewerMvp @Override public void onWorkOnline() { isImage = MarkDownProvider.isImage(url); if (isImage) { - sendToView(view -> view.onSetImageUrl(url)); + if ("svg".equalsIgnoreCase(MimeTypeMap.getFileExtensionFromUrl(url))) { + makeRestCall(RestProvider.getRepoService(isEnterprise()).getFileAsStream(url), + s -> sendToView(view -> view.onSetImageUrl(s, true))); + return; + } + sendToView(view -> view.onSetImageUrl(url, false)); return; } Observable streamObservable = MarkDownProvider.isMarkdown(url) diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFileActivity.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFileActivity.kt new file mode 100644 index 00000000..2b2edb6e --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFileActivity.kt @@ -0,0 +1,154 @@ +package com.fastaccess.ui.modules.repos.git + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.support.design.widget.TextInputLayout +import android.support.v4.app.Fragment +import android.support.v4.app.FragmentManager +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.EditText +import butterknife.BindView +import com.fastaccess.R +import com.fastaccess.data.dao.EditRepoFileModel +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Bundler +import com.fastaccess.provider.emoji.Emoji +import com.fastaccess.ui.base.BaseActivity +import com.fastaccess.ui.widgets.markdown.MarkDownLayout +import com.fastaccess.ui.widgets.markdown.MarkdownEditText + +/** + * Created by kosh on 29/08/2017. + */ +class EditRepoFileActivity : BaseActivity(), EditRepoFileMvp.View { + + @BindView(R.id.markDownLayout) lateinit var markDownLayout: MarkDownLayout + @BindView(R.id.editText) lateinit var editText: MarkdownEditText + @BindView(R.id.description) lateinit var description: TextInputLayout + @BindView(R.id.fileName) lateinit var fileName: TextInputLayout + @BindView(R.id.fileNameHolder) lateinit var fileNameHolder: View + @BindView(R.id.commitHolder) lateinit var commitHolder: View + @BindView(R.id.layoutHolder) lateinit var layoutHolder: View + + + override fun layout(): Int = R.layout.edit_repo_file_layout + + override fun isTransparent(): Boolean = false + + override fun canBack(): Boolean = true + + override fun isSecured(): Boolean = true + + override fun providePresenter(): EditRepoFilePresenter = EditRepoFilePresenter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + markDownLayout.markdownListener = this + setToolbarIcon(R.drawable.ic_clear) + if (savedInstanceState == null) { + presenter.onInit(intent) + } + val path = presenter.model?.path + if (!path.isNullOrBlank() && presenter.model?.fileName.isNullOrBlank()) { + val name = Uri.parse(path)?.lastPathSegment + title = name + fileName.editText?.setText(name) + } else if (!presenter.model?.fileName.isNullOrBlank()) { + fileName.editText?.setText(presenter.model?.fileName) + fileName.isEnabled = false + title = presenter.model?.fileName + } + toolbar?.let { + it.subtitle = "${presenter.model?.login}/${presenter.model?.repoId}" + } + } + + override fun onSetText(content: String?) { + hideProgress() + editText.setText(content) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.done_menu, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.submit) { + presenter.onSubmit(editText.text?.toString(), fileName.editText?.text?.toString(), description.editText?.text?.toString()) + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + if (menu.findItem(R.id.submit) != null) { + menu.findItem(R.id.submit).isEnabled = true + } + presenter.model?.isEdit?.let { + menu.findItem(R.id.submit).setIcon(R.drawable.ic_done) + } + return super.onPrepareOptionsMenu(menu) + } + + override fun onAppendLink(title: String?, link: String?, isLink: Boolean) { + markDownLayout.onAppendLink(title, link, isLink) + } + + override fun getEditText(): EditText = editText + + override fun getSavedText(): CharSequence? = editText.savedText + + override fun fragmentManager(): FragmentManager = supportFragmentManager + + @SuppressLint("SetTextI18n") + override fun onEmojiAdded(emoji: Emoji?) { + markDownLayout.onEmojiAdded(emoji) + } + + override fun onSetTextError(isEmpty: Boolean) { + editText.error = if (isEmpty) getString(R.string.required_field) else null + } + + override fun onSetDescriptionError(isEmpty: Boolean) { + description.error = if (isEmpty) getString(R.string.required_field) else null + } + + override fun onSetFilenameError(isEmpty: Boolean) { + fileName.error = if (isEmpty) getString(R.string.required_field) else null + } + + override fun onSuccessfullyCommitted() { + setResult(Activity.RESULT_OK) + finish() + } + + companion object { + val EDIT_RQ = 2017 + + fun startForResult(activity: Activity, model: EditRepoFileModel, isEnterprise: Boolean) { + val bundle = Bundler.start() + .put(BundleConstant.IS_ENTERPRISE, isEnterprise) + .put(BundleConstant.ITEM, model) + .end() + val intent = Intent(activity, EditRepoFileActivity::class.java) + intent.putExtras(bundle) + activity.startActivityForResult(intent, EDIT_RQ) + } + + fun startForResult(fragment: Fragment, model: EditRepoFileModel, isEnterprise: Boolean) { + val bundle = Bundler.start() + .put(BundleConstant.IS_ENTERPRISE, isEnterprise) + .put(BundleConstant.ITEM, model) + .end() + val intent = Intent(fragment.context, EditRepoFileActivity::class.java) + intent.putExtras(bundle) + fragment.startActivityForResult(intent, EDIT_RQ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFileMvp.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFileMvp.kt new file mode 100644 index 00000000..618eedc9 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFileMvp.kt @@ -0,0 +1,28 @@ +package com.fastaccess.ui.modules.repos.git + +import android.content.Intent +import com.fastaccess.ui.base.mvp.BaseMvp +import com.fastaccess.ui.modules.editor.emoji.EmojiMvp +import com.fastaccess.ui.modules.editor.popup.EditorLinkImageMvp +import com.fastaccess.ui.widgets.markdown.MarkDownLayout + +/** + * Created by kosh on 29/08/2017. + */ +interface EditRepoFileMvp { + + interface View : BaseMvp.FAView, EditorLinkImageMvp.EditorLinkCallback, + MarkDownLayout.MarkdownListener, EmojiMvp.EmojiCallback { + + fun onSetText(content: String?) + fun onSetTextError(isEmpty: Boolean) + fun onSetDescriptionError(isEmpty: Boolean) + fun onSetFilenameError(isEmpty: Boolean) + fun onSuccessfullyCommitted() + } + + interface Presenter { + fun onInit(intent: Intent?) + fun onSubmit(text: String?, filename: String?, description: String?) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFilePresenter.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFilePresenter.kt new file mode 100644 index 00000000..f042656f --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/git/EditRepoFilePresenter.kt @@ -0,0 +1,67 @@ +package com.fastaccess.ui.modules.repos.git + +import android.content.Intent +import android.util.Base64 +import com.fastaccess.data.dao.CommitRequestModel +import com.fastaccess.data.dao.EditRepoFileModel +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Logger +import com.fastaccess.provider.rest.RestProvider +import com.fastaccess.ui.base.mvp.presenter.BasePresenter + +/** + * Created by kosh on 29/08/2017. + */ +class EditRepoFilePresenter : BasePresenter(), EditRepoFileMvp.Presenter { + + @com.evernote.android.state.State var model: EditRepoFileModel? = null + + var downloadedContent: String? = null + + override fun onInit(intent: Intent?) { + if (downloadedContent.isNullOrBlank()) { + intent?.let { + it.extras?.let { + model = it.getParcelable(BundleConstant.ITEM) + Logger.e(model) + loadContent() + } + } + } else { + sendToView { it.onSetText(downloadedContent) } + } + } + + override fun onSubmit(text: String?, filename: String?, description: String?) { + if (model?.login.isNullOrBlank() || model?.repoId.isNullOrBlank()) return + + sendToView { + it.onSetTextError(text.isNullOrBlank()) + it.onSetFilenameError(filename.isNullOrBlank()) + it.onSetDescriptionError(description.isNullOrBlank()) + } + if (!text.isNullOrBlank() && !description.isNullOrBlank() && !filename.isNullOrBlank()) { + model?.let { + val commitModel = CommitRequestModel(description!!, Base64.encodeToString(text!!.toByteArray(), Base64.DEFAULT), it.sha) + val observable = RestProvider.getContentService(isEnterprise).updateCreateFile(it.login, it.repoId, + if (it.path.isNullOrBlank()) { + filename!! + } else { + if (it.path!!.endsWith("/")) { + "${it.path}$filename" + } else { + "${it.path}" + } + }, it.ref, commitModel) + makeRestCall(observable, { sendToView { it.onSuccessfullyCommitted() } }) + } + } + } + + private fun loadContent() { + model?.contentUrl?.let { + makeRestCall(RestProvider.getRepoService(isEnterprise) + .getFileAsStream(it), { sendToView({ v -> v.onSetText(it) }) }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/git/delete/DeleteContentFileCallback.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/git/delete/DeleteContentFileCallback.kt new file mode 100644 index 00000000..9713a647 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/git/delete/DeleteContentFileCallback.kt @@ -0,0 +1,9 @@ +package com.fastaccess.ui.modules.repos.git.delete + +/** + * Created by Hashemsergani on 02/09/2017. + */ +interface DeleteContentFileCallback { + + fun onDelete(message: String, position: Int) +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/git/delete/DeleteFileBottomSheetFragment.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/git/delete/DeleteFileBottomSheetFragment.kt new file mode 100644 index 00000000..f8208a56 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/git/delete/DeleteFileBottomSheetFragment.kt @@ -0,0 +1,73 @@ +package com.fastaccess.ui.modules.repos.git.delete + +import android.content.Context +import android.os.Bundle +import android.support.design.widget.TextInputLayout +import android.view.View +import butterknife.BindView +import butterknife.OnClick +import com.fastaccess.R +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Bundler +import com.fastaccess.helper.InputHelper +import com.fastaccess.ui.base.BaseBottomSheetDialog + +/** + * Created by Hashemsergani on 02/09/2017. + */ +class DeleteFileBottomSheetFragment : BaseBottomSheetDialog() { + + @BindView(R.id.description) lateinit var description: TextInputLayout + @BindView(R.id.fileName) lateinit var fileName: TextInputLayout + + private var deleteCallback: DeleteContentFileCallback? = null + + + @OnClick(R.id.delete) fun onDeleteClicked() { + description.error = if (InputHelper.isEmpty(description)) getString(R.string.required_field) else null + if (!InputHelper.isEmpty(description)) { + val position = arguments?.getInt(BundleConstant.EXTRA) + position?.let { + deleteCallback?.onDelete(InputHelper.toString(description), position) + } + dismiss() + } + } + + @OnClick(R.id.cancel) fun onCancel() { + dismiss() + } + + override fun onAttach(context: Context?) { + super.onAttach(context) + if (parentFragment is DeleteContentFileCallback) { + deleteCallback = parentFragment as DeleteContentFileCallback + } else if (context is DeleteContentFileCallback) { + deleteCallback = context + } + } + + override fun onDetach() { + deleteCallback = null + super.onDetach() + } + + override fun layoutRes(): Int = R.layout.delete_repo_file_layout + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fileName.isEnabled = false + fileName.editText?.setText(arguments.getString(BundleConstant.ITEM)) + } + + companion object { + fun newInstance(position: Int, path: String): DeleteFileBottomSheetFragment { + val fragment = DeleteFileBottomSheetFragment() + fragment.arguments = Bundler.start() + .put(BundleConstant.EXTRA, position) + .put(BundleConstant.ITEM, path) + .end() + return fragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/RepoIssuesPagerFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/RepoIssuesPagerFragment.java index 13559861..63a8c197 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/RepoIssuesPagerFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/RepoIssuesPagerFragment.java @@ -1,5 +1,6 @@ package com.fastaccess.ui.modules.repos.issues; +import android.content.Context; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -9,6 +10,7 @@ import android.view.View; import android.widget.TextView; import com.annimon.stream.Stream; +import com.evernote.android.state.State; import com.fastaccess.R; import com.fastaccess.data.dao.FragmentPagerAdapterModel; import com.fastaccess.data.dao.TabsCountStateModel; @@ -17,6 +19,7 @@ import com.fastaccess.helper.Bundler; import com.fastaccess.helper.ViewHelper; import com.fastaccess.ui.adapter.FragmentsPagerAdapter; import com.fastaccess.ui.base.BaseFragment; +import com.fastaccess.ui.modules.repos.RepoPagerMvp; import com.fastaccess.ui.modules.repos.issues.issue.RepoClosedIssuesFragment; import com.fastaccess.ui.modules.repos.issues.issue.RepoOpenedIssuesFragment; import com.fastaccess.ui.widgets.SpannableBuilder; @@ -25,7 +28,6 @@ import com.fastaccess.ui.widgets.ViewPagerView; import java.util.HashSet; import butterknife.BindView; -import com.evernote.android.state.State; /** * Created by Kosh on 31 Dec 2016, 1:36 AM @@ -37,6 +39,7 @@ public class RepoIssuesPagerFragment extends BaseFragment counts = new HashSet<>(); + private RepoPagerMvp.View repoCallback; public static RepoIssuesPagerFragment newInstance(@NonNull String repoId, @NonNull String login) { RepoIssuesPagerFragment view = new RepoIssuesPagerFragment(); @@ -47,6 +50,20 @@ public class RepoIssuesPagerFragment extends BaseFragment getLoadMore() { if (onLoadMore == null) { - onLoadMore = new OnLoadMore<>(getPresenter()); + onLoadMore = new OnLoadMore(getPresenter()) { + @Override public void onScrolled(boolean isUp) { + super.onScrolled(isUp); + if (pagerCallback != null) pagerCallback.onScrolled(isUp); + } + }; } onLoadMore.setParameter(IssueState.closed); return onLoadMore; diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoIssuesPresenter.java b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoIssuesPresenter.java index b45dac4e..1809861d 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoIssuesPresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoIssuesPresenter.java @@ -20,6 +20,8 @@ import com.fastaccess.ui.base.mvp.presenter.BasePresenter; import java.util.ArrayList; import java.util.List; +import io.reactivex.Observable; + /** * Created by Kosh on 03 Dec 2016, 3:48 PM */ @@ -76,27 +78,32 @@ class RepoIssuesPresenter extends BasePresenter implements R sortBy = "updated"; } setCurrentPage(page); - makeRestCall(RestProvider.getIssueService(isEnterprise()).getRepositoryIssues(login, repoId, parameter.name(), sortBy, page), - issues -> { - lastPage = issues.getLast(); - List filtered = Stream.of(issues.getItems()) - .filter(issue -> issue.getPullRequest() == null) - .toList(); - if (getCurrentPage() == 1) { - manageDisposable(Issue.save(filtered, repoId, login)); - } - sendToView(view -> view.onNotifyAdapter(filtered, page)); - }); + String finalSortBy = sortBy; + makeRestCall(RestProvider.getIssueService(isEnterprise()) + .getRepositoryIssues(login, repoId, parameter.name(), sortBy, page) + .flatMap(issues -> { + lastPage = issues.getLast(); + List filtered = Stream.of(issues.getItems()) + .filter(issue -> issue.getPullRequest() == null) + .toList(); + if (filtered != null) { + if (filtered.size() < 10 && issues.getNext() > 1) { + setCurrentPage(getCurrentPage() + 1); + return grabMoreIssues(filtered, parameter.name(), finalSortBy, getCurrentPage()); + } + return Observable.just(filtered); + } + return Observable.just(new ArrayList()); + }) + .doOnNext(filtered -> { + if (getCurrentPage() == 1) { + Issue.save(filtered, repoId, login); + } + }), + issues -> sendToView(view -> view.onNotifyAdapter(issues, page))); return true; } - private void onCallCountApi(@NonNull IssueState issueState) { - manageDisposable(RxHelper.getObservable(RestProvider.getIssueService(isEnterprise()) - .getIssuesWithCount(RepoQueryProvider.getIssuesPullRequestQuery(login, repoId, issueState, false), 1)) - .subscribe(pullRequestPageable -> sendToView(view -> view.onUpdateCount(pullRequestPageable.getTotalCount())), - Throwable::printStackTrace)); - } - @Override public void onFragmentCreated(@NonNull Bundle bundle, @NonNull IssueState issueState) { repoId = bundle.getString(BundleConstant.ID); login = bundle.getString(BundleConstant.EXTRA); @@ -144,4 +151,33 @@ class RepoIssuesPresenter extends BasePresenter implements R @Override public void onItemLongClick(int position, View v, Issue item) { if (getView() != null) getView().onShowIssuePopup(item); } + + private void onCallCountApi(@NonNull IssueState issueState) { + manageDisposable(RxHelper.getObservable(RestProvider.getIssueService(isEnterprise()) + .getIssuesWithCount(RepoQueryProvider.getIssuesPullRequestQuery(login, repoId, issueState, false), 1)) + .subscribe(pullRequestPageable -> sendToView(view -> view.onUpdateCount(pullRequestPageable.getTotalCount())), + Throwable::printStackTrace)); + } + + private Observable> grabMoreIssues(@NonNull List issues, @NonNull String state, @NonNull String sortBy, int page) { + return RestProvider.getIssueService(isEnterprise()).getRepositoryIssues(login, repoId, state, sortBy, page) + .flatMap(issuePageable -> { + if (issuePageable != null) { + lastPage = issuePageable.getLast(); + List filtered = Stream.of(issuePageable.getItems()) + .filter(issue -> issue.getPullRequest() == null) + .toList(); + if (filtered != null) { + issues.addAll(filtered); + if (issues.size() < 10 && issuePageable.getNext() > 1) { + setCurrentPage(getCurrentPage() + 1); + return grabMoreIssues(issues, state, sortBy, getCurrentPage()); + } + return Observable.just(filtered); + } + } + return Observable.just(new ArrayList()); + }); + } + } diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoOpenedIssuesFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoOpenedIssuesFragment.java index 9de1e247..c27db2da 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoOpenedIssuesFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/RepoOpenedIssuesFragment.java @@ -162,7 +162,12 @@ public class RepoOpenedIssuesFragment extends BaseFragment getLoadMore() { if (onLoadMore == null) { - onLoadMore = new OnLoadMore<>(getPresenter()); + onLoadMore = new OnLoadMore(getPresenter()) { + @Override public void onScrolled(boolean isUp) { + super.onScrolled(isUp); + if (pagerCallback != null) pagerCallback.onScrolled(isUp); + } + }; } onLoadMore.setParameter(IssueState.open); return onLoadMore; diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/IssuePagerActivity.java b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/IssuePagerActivity.java index ca6ab1d7..4fa91883 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/IssuePagerActivity.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/IssuePagerActivity.java @@ -46,6 +46,7 @@ import com.fastaccess.ui.widgets.ViewPagerView; import com.fastaccess.ui.widgets.dialog.MessageDialogView; import java.util.ArrayList; +import java.util.List; import butterknife.BindView; import butterknife.OnClick; @@ -256,7 +257,7 @@ public class IssuePagerActivity extends BaseActivity getNamesToTag() { + IssueTimelineFragment fragment = getIssueTimelineFragment(); + if (fragment != null) { + return fragment.getNamesToTag(); + } + return new ArrayList<>(); + } + private void hideShowFab() { if (getPresenter().isLocked() && !getPresenter().isOwner()) { getSupportFragmentManager().beginTransaction().hide(commentEditorFragment).commit(); diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineFragment.java index 3502822b..864dcd59 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineFragment.java @@ -36,6 +36,7 @@ import com.fastaccess.ui.widgets.dialog.MessageDialogView; import com.fastaccess.ui.widgets.recyclerview.DynamicRecyclerView; import com.fastaccess.ui.widgets.recyclerview.scroll.RecyclerViewFastScroller; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -217,7 +218,22 @@ public class IssueTimelineFragment extends BaseFragment getNamesToTag() { + return CommentsHelper.getUsersByTimeline(adapter.getData()); } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineMvp.java b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineMvp.java index ed964702..30b5901c 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineMvp.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelineMvp.java @@ -57,6 +57,8 @@ public interface IssueTimelineMvp { void onHandleComment(String text, @Nullable Bundle bundle); void addNewComment(@NonNull TimelineModel timelineModel); + + @NonNull ArrayList getNamesToTag(); } interface Presenter extends BaseMvp.FAPresenter, BaseViewHolder.OnItemClickListener, diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelinePresenter.java b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelinePresenter.java index f907237b..bc072157 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelinePresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/issues/issue/details/timeline/IssueTimelinePresenter.java @@ -46,6 +46,7 @@ import lombok.Getter; private int page; private int previousTotal; private int lastPage = Integer.MAX_VALUE; + @com.evernote.android.state.State boolean isCollaborator; @Override public boolean isPreviouslyReacted(long commentId, int vId) { return getReactionsProvider().isPreviouslyReacted(commentId, vId); @@ -60,7 +61,7 @@ import lombok.Getter; PopupMenu popupMenu = new PopupMenu(v.getContext(), v); popupMenu.inflate(R.menu.comments_menu); String username = Login.getUser().getLogin(); - boolean isOwner = CommentsHelper.isOwner(username, issue.getLogin(), item.getComment().getUser().getLogin()); + boolean isOwner = CommentsHelper.isOwner(username, issue.getLogin(), item.getComment().getUser().getLogin()) || isCollaborator; popupMenu.getMenu().findItem(R.id.delete).setVisible(isOwner); popupMenu.getMenu().findItem(R.id.edit).setVisible(isOwner); popupMenu.setOnMenuItemClickListener(item1 -> { @@ -97,13 +98,13 @@ import lombok.Getter; SourceModel sourceModel = issueEventModel.getSource(); if (sourceModel != null) { if (sourceModel.getCommit() != null) { - SchemeParser.launchUri(v.getContext(), Uri.parse(sourceModel.getCommit().getUrl())); - } else if (sourceModel.getIssue() != null) { - SchemeParser.launchUri(v.getContext(), Uri.parse(sourceModel.getIssue().getUrl())); + SchemeParser.launchUri(v.getContext(), sourceModel.getCommit().getUrl()); } else if (sourceModel.getPullRequest() != null) { - SchemeParser.launchUri(v.getContext(), Uri.parse(sourceModel.getPullRequest().getUrl())); + SchemeParser.launchUri(v.getContext(), sourceModel.getPullRequest().getUrl()); + } else if (sourceModel.getIssue() != null) { + SchemeParser.launchUri(v.getContext(), sourceModel.getIssue().getHtmlUrl()); } else if (sourceModel.getRepository() != null) { - SchemeParser.launchUri(v.getContext(), Uri.parse(sourceModel.getRepository().getUrl())); + SchemeParser.launchUri(v.getContext(), sourceModel.getRepository().getUrl()); } } } @@ -112,7 +113,8 @@ import lombok.Getter; PopupMenu popupMenu = new PopupMenu(v.getContext(), v); popupMenu.inflate(R.menu.comments_menu); String username = Login.getUser().getLogin(); - boolean isOwner = CommentsHelper.isOwner(username, item.getIssue().getLogin(), item.getIssue().getUser().getLogin()); + boolean isOwner = CommentsHelper.isOwner(username, item.getIssue().getLogin(), + item.getIssue().getUser().getLogin()) || isCollaborator; popupMenu.getMenu().findItem(R.id.edit).setVisible(isOwner); popupMenu.setOnMenuItemClickListener(item1 -> { if (getView() == null) return false; @@ -259,6 +261,11 @@ import lombok.Getter; setCurrentPage(page); String login = parameter.getLogin(); String repoId = parameter.getRepoId(); + if (page == 1) { + manageObservable(RestProvider.getRepoService(isEnterprise()).isCollaborator(login, repoId, + Login.getUser().getLogin()) + .doOnNext(booleanResponse -> isCollaborator = booleanResponse.code() == 204)); + } int number = parameter.getNumber(); Observable> observable = RestProvider.getIssueService(isEnterprise()) .getTimeline(login, repoId, number, page) diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/RepoProjectsFragmentPager.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/RepoProjectsFragmentPager.kt new file mode 100644 index 00000000..61467b61 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/RepoProjectsFragmentPager.kt @@ -0,0 +1,83 @@ +package com.fastaccess.ui.modules.repos.projects + +import android.os.Bundle +import android.support.design.widget.TabLayout +import android.view.View +import butterknife.BindView +import com.fastaccess.R +import com.fastaccess.data.dao.FragmentPagerAdapterModel +import com.fastaccess.data.dao.TabsCountStateModel +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Bundler +import com.fastaccess.helper.Logger +import com.fastaccess.helper.ViewHelper +import com.fastaccess.ui.adapter.FragmentsPagerAdapter +import com.fastaccess.ui.base.BaseFragment +import com.fastaccess.ui.base.mvp.BaseMvp +import com.fastaccess.ui.base.mvp.presenter.BasePresenter +import com.fastaccess.ui.modules.repos.RepoPagerMvp +import com.fastaccess.ui.widgets.SpannableBuilder +import com.fastaccess.ui.widgets.ViewPagerView + +/** + * Created by kosh on 09/09/2017. + */ +class RepoProjectsFragmentPager : BaseFragment>(), RepoPagerMvp.TabsBadgeListener { + + @BindView(R.id.tabs) lateinit var tabs: TabLayout + @BindView(R.id.pager) lateinit var pager: ViewPagerView + private var counts = hashSetOf() + + override fun fragmentLayout(): Int = R.layout.centered_tabbed_viewpager + + override fun onSaveInstanceState(outState: Bundle?) { + super.onSaveInstanceState(outState) + if (counts.isNotEmpty()) { + outState?.putSerializable("counts", counts) + } + } + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + pager.adapter = FragmentsPagerAdapter(childFragmentManager, FragmentPagerAdapterModel.buildForRepoProjects(context, + arguments.getString(BundleConstant.EXTRA), arguments.getString(BundleConstant.ID))) + tabs.setupWithViewPager(pager) + if (savedInstanceState != null) { + @Suppress("UNCHECKED_CAST") + counts = savedInstanceState.getSerializable("counts") as HashSet + Logger.e(counts) + if (!counts.isEmpty()) counts.onEach { updateCount(it) } + } + } + + override fun providePresenter(): BasePresenter = BasePresenter() + + override fun onSetBadge(tabIndex: Int, count: Int) { + val model = TabsCountStateModel() + model.tabIndex = tabIndex + model.count = count + counts.add(model) + tabs?.let { updateCount(model) } + } + + private fun updateCount(model: TabsCountStateModel) { + val tv = ViewHelper.getTabTextView(tabs, model.tabIndex) + tv.text = SpannableBuilder.builder() + .append(if (model.tabIndex == 0) getString(R.string.opened) else getString(R.string.closed)) + .append(" ") + .append("(") + .bold(model.count.toString()) + .append(")") + } + + companion object { + val TAG = RepoProjectsFragmentPager::class.java.simpleName + fun newInstance(login: String, repoId: String): RepoProjectsFragmentPager { + val fragment = RepoProjectsFragmentPager() + fragment.arguments = Bundler.start() + .put(BundleConstant.ID, repoId) + .put(BundleConstant.EXTRA, login) + .end() + return fragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnFragment.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnFragment.kt new file mode 100644 index 00000000..bda5cd4d --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnFragment.kt @@ -0,0 +1,133 @@ +package com.fastaccess.ui.modules.repos.projects.columns + +import android.os.Bundle +import android.support.annotation.StringRes +import android.support.v4.widget.SwipeRefreshLayout +import android.view.View +import butterknife.BindView +import butterknife.OnClick +import com.fastaccess.R +import com.fastaccess.data.dao.ProjectCardModel +import com.fastaccess.data.dao.ProjectColumnModel +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Bundler +import com.fastaccess.provider.rest.loadmore.OnLoadMore +import com.fastaccess.ui.adapter.ColumnCardAdapter +import com.fastaccess.ui.base.BaseFragment +import com.fastaccess.ui.widgets.FontTextView +import com.fastaccess.ui.widgets.StateLayout +import com.fastaccess.ui.widgets.recyclerview.DynamicRecyclerView +import com.fastaccess.ui.widgets.recyclerview.scroll.RecyclerViewFastScroller + +/** + * Created by Hashemsergani on 11.09.17. + */ +class ProjectColumnFragment : BaseFragment(), ProjectColumnMvp.View { + + @BindView(R.id.recycler) lateinit var recycler: DynamicRecyclerView + @BindView(R.id.refresh) lateinit var refresh: SwipeRefreshLayout + @BindView(R.id.stateLayout) lateinit var stateLayout: StateLayout + @BindView(R.id.fastScroller) lateinit var fastScroller: RecyclerViewFastScroller + @BindView(R.id.columnName) lateinit var columnName: FontTextView + @BindView(R.id.editColumnHolder) lateinit var editColumnHolder: View + + private var onLoadMore: OnLoadMore? = null + private val adapter by lazy { ColumnCardAdapter(presenter.getCards(), isOwner()) } + + @OnClick(R.id.editColumn) fun onEditColumn() {} + @OnClick(R.id.deleteColumn) fun onDeleteColumn() {} + @OnClick(R.id.addCard) fun onAddCard() {} + + + override fun onNotifyAdapter(items: List?, page: Int) { + hideProgress() + if (items == null || items.isEmpty()) { + adapter.clear() + return + } + if (page <= 1) { + adapter.insertItems(items) + } else { + adapter.addItems(items) + } + } + + override fun getLoadMore(): OnLoadMore { + if (onLoadMore == null) { + onLoadMore = OnLoadMore(presenter) + } + onLoadMore!!.parameter = getColumn().id + return onLoadMore!! + } + + override fun providePresenter(): ProjectColumnPresenter = ProjectColumnPresenter() + + override fun fragmentLayout(): Int = R.layout.project_columns_layout + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + val column = getColumn() + columnName.text = column.name + refresh.setOnRefreshListener { presenter.onCallApi(1, column.id) } + stateLayout.setOnReloadListener { presenter.onCallApi(1, column.id) } + stateLayout.setEmptyText(R.string.no_cards) + recycler.setEmptyView(stateLayout, refresh) + getLoadMore().initialize(presenter.currentPage, presenter.previousTotal) + adapter.listener = presenter + recycler.adapter = adapter + recycler.addOnScrollListener(getLoadMore()) + fastScroller.attachRecyclerView(recycler) + if (presenter.getCards().isEmpty() && !presenter.isApiCalled) { + presenter.onCallApi(1, column.id) + } + } + + override fun showProgress(@StringRes resId: Int) { + refresh.isRefreshing = true + stateLayout.showProgress() + } + + override fun hideProgress() { + refresh.isRefreshing = false + stateLayout.hideProgress() + } + + override fun showErrorMessage(message: String) { + showReload() + super.showErrorMessage(message) + } + + override fun showMessage(titleRes: Int, msgRes: Int) { + showReload() + super.showMessage(titleRes, msgRes) + } + + override fun onScrollTop(index: Int) { + super.onScrollTop(index) + recycler?.scrollToPosition(0) + } + + override fun onDestroyView() { + recycler.removeOnScrollListener(getLoadMore()) + super.onDestroyView() + } + + private fun showReload() { + hideProgress() + stateLayout.showReload(adapter.itemCount) + } + + private fun getColumn(): ProjectColumnModel = arguments.getParcelable(BundleConstant.ITEM) + + private fun isOwner(): Boolean = arguments.getBoolean(BundleConstant.EXTRA) + + companion object { + fun newInstance(column: ProjectColumnModel, isCollaborator: Boolean): ProjectColumnFragment { + val fragment = ProjectColumnFragment() + fragment.arguments = Bundler.start() + .put(BundleConstant.ITEM, column) + .put(BundleConstant.EXTRA, isCollaborator) + .end() + return fragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnMvp.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnMvp.kt new file mode 100644 index 00000000..ef244b28 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnMvp.kt @@ -0,0 +1,21 @@ +package com.fastaccess.ui.modules.repos.projects.columns + +import com.fastaccess.data.dao.ProjectCardModel +import com.fastaccess.provider.rest.loadmore.OnLoadMore +import com.fastaccess.ui.base.mvp.BaseMvp +import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder + +/** + * Created by Hashemsergani on 11.09.17. + */ + +interface ProjectColumnMvp { + interface View : BaseMvp.FAView { + fun onNotifyAdapter(items: List?, page: Int) + fun getLoadMore(): OnLoadMore + } + + interface Presenter : BaseViewHolder.OnItemClickListener, BaseMvp.PaginationListener { + fun getCards(): ArrayList + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnPresenter.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnPresenter.kt new file mode 100644 index 00000000..9fca8046 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/columns/ProjectColumnPresenter.kt @@ -0,0 +1,59 @@ +package com.fastaccess.ui.modules.repos.projects.columns + +import android.view.View +import com.fastaccess.data.dao.ProjectCardModel +import com.fastaccess.helper.Logger +import com.fastaccess.provider.rest.RestProvider +import com.fastaccess.ui.base.mvp.presenter.BasePresenter +import java.util.* + +/** + * Created by Hashemsergani on 11.09.17. + */ + +class ProjectColumnPresenter : BasePresenter(), ProjectColumnMvp.Presenter { + + + private val projects = ArrayList() + private var page: Int = 0 + private var previousTotal: Int = 0 + private var lastPage = Integer.MAX_VALUE + + override fun onItemClick(position: Int, v: View?, item: ProjectCardModel?) {} + + override fun onItemLongClick(position: Int, v: View?, item: ProjectCardModel?) {} + + override fun getCards(): ArrayList = projects + + override fun getCurrentPage(): Int = page + + override fun getPreviousTotal(): Int = previousTotal + + override fun setCurrentPage(page: Int) { + this.page = page + } + + override fun setPreviousTotal(previousTotal: Int) { + this.previousTotal = previousTotal + } + + override fun onCallApi(page: Int, parameter: Long?): Boolean { + if (page == 1) { + lastPage = Integer.MAX_VALUE + sendToView { view -> view.getLoadMore().reset() } + } + if (page > lastPage || lastPage == 0) { + sendToView({ it.hideProgress() }) + return false + } + currentPage = page + makeRestCall(RestProvider.getProjectsService(isEnterprise).getProjectCards(parameter!!, page), + { response -> + lastPage = response.last + Logger.e(response.items as List?) + sendToView({ it.onNotifyAdapter(response.items, page) }) + }) + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerActivity.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerActivity.kt new file mode 100644 index 00000000..a756458d --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerActivity.kt @@ -0,0 +1,126 @@ +package com.fastaccess.ui.modules.repos.projects.details + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.support.v4.view.ViewPager +import android.view.MenuItem +import android.view.View +import butterknife.BindView +import com.airbnb.lottie.LottieAnimationView +import com.fastaccess.R +import com.fastaccess.data.dao.FragmentPagerAdapterModel +import com.fastaccess.data.dao.NameParser +import com.fastaccess.data.dao.ProjectColumnModel +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Bundler +import com.fastaccess.ui.adapter.FragmentsPagerAdapter +import com.fastaccess.ui.base.BaseActivity +import com.fastaccess.ui.modules.repos.RepoPagerActivity +import com.fastaccess.ui.widgets.CardsPagerTransformerBasic + +/** + * Created by Hashemsergani on 11.09.17. + */ + +class ProjectPagerActivity : BaseActivity(), ProjectPagerMvp.View { + + + @BindView(R.id.pager) lateinit var pager: ViewPager + @BindView(R.id.loading) lateinit var loading: LottieAnimationView + + override fun canBack(): Boolean = true + + override fun isSecured(): Boolean = false + + override fun providePresenter(): ProjectPagerPresenter = ProjectPagerPresenter() + + override fun onInitPager(list: List) { + hideProgress() + pager.adapter = FragmentsPagerAdapter(supportFragmentManager, FragmentPagerAdapterModel + .buildForProjectColumns(list, presenter.isCollaborator)) + } + + override fun showMessage(titleRes: Int, msgRes: Int) { + hideProgress() + super.showMessage(titleRes, msgRes) + } + + override fun showMessage(titleRes: String, msgRes: String) { + hideProgress() + super.showMessage(titleRes, msgRes) + } + + override fun showErrorMessage(msgRes: String) { + hideProgress() + super.showErrorMessage(msgRes) + } + + override fun showProgress(resId: Int) { + loading.visibility = View.VISIBLE + loading.playAnimation() + } + + override fun hideProgress() { + loading.cancelAnimation() + loading.visibility = View.GONE + } + + override fun layout(): Int = R.layout.projects_activity_layout + + override fun isTransparent(): Boolean = true + + override fun onOptionsItemSelected(item: MenuItem?): Boolean { + return when (item?.itemId) { + android.R.id.home -> { + if (!presenter.login.isBlank() && !presenter.repoId.isBlank()) { + val nameParse = NameParser("") + nameParse.name = presenter.repoId + nameParse.username = presenter.login + nameParse.isEnterprise = isEnterprise + RepoPagerActivity.startRepoPager(this, nameParse) + } + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + pager.clipToPadding = false + val pageMargin = resources.getDimensionPixelSize(R.dimen.spacing_normal) + pager.pageMargin = pageMargin + pager.setPageTransformer(true, CardsPagerTransformerBasic(4, 10)) + pager.setPadding(pageMargin, pageMargin, pageMargin, pageMargin) + + if (savedInstanceState == null) { + presenter.onActivityCreated(intent) + } else if (presenter.getColumns().isEmpty() && !presenter.isApiCalled) { + presenter.onRetrieveColumns() + } else { + onInitPager(presenter.getColumns()) + } + } + + companion object { + fun startActivity(context: Context, login: String, repoId: String, projectId: Long) { + context.startActivity(getIntent(context, login, repoId, projectId)) + } + + fun getIntent(context: Context, login: String, repoId: String, projectId: Long): Intent { + val intent = Intent(context, ProjectPagerActivity::class.java) + intent.putExtras(Bundler.start() + .put(BundleConstant.ID, projectId) + .put(BundleConstant.ITEM, repoId) + .put(BundleConstant.EXTRA, login) + .end()) + return intent + } + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerMvp.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerMvp.kt new file mode 100644 index 00000000..2f34397d --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerMvp.kt @@ -0,0 +1,23 @@ +package com.fastaccess.ui.modules.repos.projects.details + +import android.content.Intent +import com.fastaccess.data.dao.ProjectColumnModel +import com.fastaccess.ui.base.mvp.BaseMvp + +/** + * Created by Hashemsergani on 11.09.17. + */ +interface ProjectPagerMvp { + + interface View : BaseMvp.FAView { + fun onInitPager(list: List) + } + + interface Presenter { + fun onActivityCreated(intent: Intent?) + + fun onRetrieveColumns() + + fun getColumns(): ArrayList + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerPresenter.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerPresenter.kt new file mode 100644 index 00000000..2900feb9 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/details/ProjectPagerPresenter.kt @@ -0,0 +1,66 @@ +package com.fastaccess.ui.modules.repos.projects.details + +import android.content.Intent +import com.fastaccess.R +import com.fastaccess.data.dao.Pageable +import com.fastaccess.data.dao.ProjectColumnModel +import com.fastaccess.data.dao.model.Login +import com.fastaccess.helper.BundleConstant +import com.fastaccess.provider.rest.RestProvider +import com.fastaccess.ui.base.mvp.presenter.BasePresenter +import io.reactivex.Observable +import io.reactivex.functions.BiFunction +import retrofit2.Response + +/** + * Created by Hashemsergani on 11.09.17. + */ +class ProjectPagerPresenter : BasePresenter(), ProjectPagerMvp.Presenter { + + private val columns = arrayListOf() + @com.evernote.android.state.State var projectId: Long = -1 + @com.evernote.android.state.State var repoId: String = "" + @com.evernote.android.state.State var login: String = "" + @com.evernote.android.state.State var isCollaborator: Boolean = false + + override fun getColumns(): ArrayList = columns + + + override fun onRetrieveColumns() { + makeRestCall(Observable.zip(RestProvider.getProjectsService(isEnterprise).getProjectColumns(projectId), + RestProvider.getRepoService(isEnterprise).isCollaborator(login, repoId, Login.getUser().login), + BiFunction { items: Pageable, response: Response -> + isCollaborator = response.code() == 204 + return@BiFunction items + }) + .flatMap { + if (it.items != null) { + return@flatMap Observable.just(it.items) + } + return@flatMap Observable.just(listOf()) + }, + { t -> + columns.clear() + columns.addAll(t) + sendToView { it.onInitPager(columns) } + }) + } + + override fun onActivityCreated(intent: Intent?) { + intent?.let { + it.extras?.let { + projectId = it.getLong(BundleConstant.ID) + repoId = it.getString(BundleConstant.ITEM) + login = it.getString(BundleConstant.EXTRA) + } + } + if (columns.isEmpty()) { + if (projectId > 0) + onRetrieveColumns() + else + sendToView { it.showMessage(R.string.error, R.string.unexpected_error) } + } else { + sendToView { it.onInitPager(columns) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectFragment.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectFragment.kt new file mode 100644 index 00000000..057e5a38 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectFragment.kt @@ -0,0 +1,147 @@ +package com.fastaccess.ui.modules.repos.projects.list + +import android.content.Context +import android.os.Bundle +import android.support.annotation.StringRes +import android.support.v4.widget.SwipeRefreshLayout +import android.view.View +import butterknife.BindView +import com.fastaccess.R +import com.fastaccess.data.dao.types.IssueState +import com.fastaccess.helper.BundleConstant +import com.fastaccess.helper.Bundler +import com.fastaccess.provider.rest.loadmore.OnLoadMore +import com.fastaccess.ui.adapter.ProjectsAdapter +import com.fastaccess.ui.base.BaseFragment +import com.fastaccess.ui.modules.repos.RepoPagerMvp +import com.fastaccess.ui.widgets.StateLayout +import com.fastaccess.ui.widgets.recyclerview.DynamicRecyclerView +import com.fastaccess.ui.widgets.recyclerview.scroll.RecyclerViewFastScroller +import github.RepoProjectsOpenQuery + +/** + * Created by kosh on 09/09/2017. + */ + +class RepoProjectFragment : BaseFragment(), RepoProjectMvp.View { + + @BindView(R.id.recycler) lateinit var recycler: DynamicRecyclerView + @BindView(R.id.refresh) lateinit var refresh: SwipeRefreshLayout + @BindView(R.id.stateLayout) lateinit var stateLayout: StateLayout + @BindView(R.id.fastScroller) lateinit var fastScroller: RecyclerViewFastScroller + private var onLoadMore: OnLoadMore? = null + private val adapter by lazy { ProjectsAdapter(presenter.getProjects()) } + private var badgeListener: RepoPagerMvp.TabsBadgeListener? = null + + override fun onAttach(context: Context?) { + super.onAttach(context) + if (parentFragment is RepoPagerMvp.TabsBadgeListener) { + badgeListener = parentFragment as RepoPagerMvp.TabsBadgeListener + } else if (context is RepoPagerMvp.TabsBadgeListener) { + badgeListener = context + } + } + + override fun onDetach() { + badgeListener = null + super.onDetach() + } + + override fun providePresenter(): RepoProjectPresenter = RepoProjectPresenter() + + override fun onNotifyAdapter(items: List?, page: Int) { + hideProgress() + if (items == null || items.isEmpty()) { + adapter.clear() + return + } + if (page <= 1) { + adapter.insertItems(items) + } else { + adapter.addItems(items) + } + } + + override fun onChangeTotalCount(count: Int) { + badgeListener?.onSetBadge(if (getState() == IssueState.open) 0 else 1, count) + } + + override fun getLoadMore(): OnLoadMore { + if (onLoadMore == null) { + onLoadMore = OnLoadMore(presenter) + } + onLoadMore!!.parameter = getState() + return onLoadMore!! + } + + override fun fragmentLayout(): Int = R.layout.micro_grid_refresh_list + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + stateLayout.setEmptyText(R.string.no_projects) + stateLayout.setOnReloadListener({ presenter.onCallApi(1, getState()) }) + refresh.setOnRefreshListener({ presenter.onCallApi(1, getState()) }) + recycler.setEmptyView(stateLayout, refresh) + getLoadMore().initialize(presenter.currentPage, presenter + .previousTotal) + adapter.listener = presenter + recycler.adapter = adapter + recycler.addDivider() + recycler.addOnScrollListener(getLoadMore()) + fastScroller.attachRecyclerView(recycler) + if (presenter.getProjects().isEmpty() && !presenter.isApiCalled) { + presenter.onFragmentCreate(arguments) + presenter.onCallApi(1, getState()) + } + } + + override fun showProgress(@StringRes resId: Int) { + refresh.isRefreshing = true + stateLayout.showProgress() + } + + override fun hideProgress() { + refresh.isRefreshing = false + stateLayout.hideProgress() + } + + override fun showErrorMessage(message: String) { + showReload() + super.showErrorMessage(message) + } + + override fun showMessage(titleRes: Int, msgRes: Int) { + showReload() + super.showMessage(titleRes, msgRes) + } + + override fun onScrollTop(index: Int) { + super.onScrollTop(index) + if (recycler != null) { + recycler.scrollToPosition(0) + } + } + + override fun onDestroyView() { + recycler.removeOnScrollListener(getLoadMore()) + super.onDestroyView() + } + + private fun showReload() { + hideProgress() + stateLayout.showReload(adapter.itemCount) + } + + private fun getState(): IssueState = arguments.getSerializable(BundleConstant.EXTRA_TYPE) as IssueState + + companion object { + fun newInstance(login: String, repoId: String, state: IssueState): RepoProjectFragment { + val fragment = RepoProjectFragment() + fragment.arguments = Bundler.start() + .put(BundleConstant.ID, repoId) + .put(BundleConstant.EXTRA, login) + .put(BundleConstant.EXTRA_TYPE, state) + .end() + return fragment + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectMvp.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectMvp.kt new file mode 100644 index 00000000..59db0339 --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectMvp.kt @@ -0,0 +1,29 @@ +package com.fastaccess.ui.modules.repos.projects.list + +import android.os.Bundle +import com.fastaccess.data.dao.types.IssueState +import com.fastaccess.provider.rest.loadmore.OnLoadMore +import com.fastaccess.ui.base.mvp.BaseMvp +import com.fastaccess.ui.widgets.recyclerview.BaseViewHolder +import github.RepoProjectsOpenQuery +import java.util.* + +/** + * Created by kosh on 09/09/2017. + */ +interface RepoProjectMvp { + + interface View : BaseMvp.FAView { + fun onNotifyAdapter(items: List?, page: Int) + fun getLoadMore(): OnLoadMore + fun onChangeTotalCount(count: Int) + } + + interface Presenter : BaseViewHolder.OnItemClickListener, + BaseMvp.PaginationListener { + + fun onFragmentCreate(bundle: Bundle?) + + fun getProjects(): ArrayList + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectPresenter.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectPresenter.kt new file mode 100644 index 00000000..782596aa --- /dev/null +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/projects/list/RepoProjectPresenter.kt @@ -0,0 +1,137 @@ +package com.fastaccess.ui.modules.repos.projects.list + +import android.os.Bundle +import android.view.View +import com.apollographql.apollo.rx2.Rx2Apollo +import com.fastaccess.data.dao.types.IssueState +import com.fastaccess.helper.BundleConstant +import com.fastaccess.provider.rest.ApolloProdivder +import com.fastaccess.ui.base.mvp.presenter.BasePresenter +import com.fastaccess.ui.modules.repos.projects.details.ProjectPagerActivity +import github.RepoProjectsClosedQuery +import github.RepoProjectsOpenQuery +import io.reactivex.Observable + +/** + * Created by kosh on 09/09/2017. + */ +class RepoProjectPresenter : BasePresenter(), RepoProjectMvp.Presenter { + + private val projects = arrayListOf() + private var page: Int = 0 + private var previousTotal: Int = 0 + private var lastPage = Integer.MAX_VALUE + @com.evernote.android.state.State var login: String = "" + @com.evernote.android.state.State var repoId: String = "" + var count: Int = 0 + val pages = arrayListOf() + + override fun onItemClick(position: Int, v: View, item: RepoProjectsOpenQuery.Node) { + ProjectPagerActivity.startActivity(v.context, login, repoId, item.number().toLong()) + } + + override fun onItemLongClick(position: Int, v: View?, item: RepoProjectsOpenQuery.Node?) {} + + override fun onFragmentCreate(bundle: Bundle?) { + bundle?.let { + repoId = it.getString(BundleConstant.ID) + login = it.getString(BundleConstant.EXTRA) + } + } + + override fun getProjects(): ArrayList = projects + + override fun getCurrentPage(): Int = page + + override fun getPreviousTotal(): Int = previousTotal + + override fun setCurrentPage(page: Int) { + this.page = page + } + + override fun setPreviousTotal(previousTotal: Int) { + this.previousTotal = previousTotal + } + + override fun onCallApi(page: Int, parameter: IssueState?): Boolean { + if (page == 1) { + lastPage = Integer.MAX_VALUE + sendToView { view -> view.getLoadMore().reset() } + } + if (page > lastPage || lastPage == 0 || parameter == null) { + sendToView({ it.hideProgress() }) + return false + } + currentPage = page + val apollo = ApolloProdivder.getApollo(isEnterprise) + if (parameter == IssueState.open) { + val query = RepoProjectsOpenQuery.builder() + .name(repoId) + .owner(login) + .page(getPage()) + .build() + makeRestCall(Rx2Apollo.from(apollo.query(query)) + .flatMap { + val list = arrayListOf() + it.data()?.repository()?.let { + it.projects().let { + pages.clear() + count = it.totalCount() + it.edges()?.let { + pages.addAll(it.map { it.cursor() }) + } + it.nodes()?.let { + list.addAll(it) + } + } + } + return@flatMap Observable.just(list) + }, + { + sendToView({ v -> + v.onNotifyAdapter(it, page) + if (page == 1) v.onChangeTotalCount(count) + }) + }) + } else { + val query = RepoProjectsClosedQuery.builder() + .name(repoId) + .owner(login) + .page(getPage()) + .build() + makeRestCall(Rx2Apollo.from(apollo.query(query)) + .flatMap { + val list = arrayListOf() + it.data()?.repository()?.let { + it.projects().let { + pages.clear() + count = it.totalCount() + it.edges()?.let { + pages.addAll(it.map { it.cursor() }) + } + it.nodes()?.let { + val toConvert = arrayListOf() + it.onEach { + val columns = RepoProjectsOpenQuery.Columns(it.columns().__typename(), it.columns().totalCount()) + val node = RepoProjectsOpenQuery.Node(it.__typename(), it.name(), it.number(), it.body(), + it.createdAt(), it.id(), it.viewerCanUpdate(), columns) + toConvert.add(node) + } + list.addAll(toConvert) + } + } + } + return@flatMap Observable.just(list) + }, + { + sendToView({ v -> + v.onNotifyAdapter(it, page) + if (page == 1) v.onChangeTotalCount(count) + }) + }) + } + return true + } + + private fun getPage(): String? = if (pages.isNotEmpty()) pages.last() else null +} \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/RepoPullRequestPagerFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/RepoPullRequestPagerFragment.java index 17a26fdb..11bb369f 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/RepoPullRequestPagerFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/RepoPullRequestPagerFragment.java @@ -1,5 +1,6 @@ package com.fastaccess.ui.modules.repos.pull_requests; +import android.content.Context; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -9,6 +10,7 @@ import android.view.View; import android.widget.TextView; import com.annimon.stream.Stream; +import com.evernote.android.state.State; import com.fastaccess.R; import com.fastaccess.data.dao.FragmentPagerAdapterModel; import com.fastaccess.data.dao.TabsCountStateModel; @@ -17,13 +19,13 @@ import com.fastaccess.helper.Bundler; import com.fastaccess.helper.ViewHelper; import com.fastaccess.ui.adapter.FragmentsPagerAdapter; import com.fastaccess.ui.base.BaseFragment; +import com.fastaccess.ui.modules.repos.RepoPagerMvp; import com.fastaccess.ui.widgets.SpannableBuilder; import com.fastaccess.ui.widgets.ViewPagerView; import java.util.HashSet; import butterknife.BindView; -import com.evernote.android.state.State; /** * Created by Kosh on 31 Dec 2016, 1:36 AM @@ -37,7 +39,7 @@ public class RepoPullRequestPagerFragment extends BaseFragment counts = new HashSet<>(); - + private RepoPagerMvp.View repoCallback; public static RepoPullRequestPagerFragment newInstance(@NonNull String repoId, @NonNull String login) { RepoPullRequestPagerFragment view = new RepoPullRequestPagerFragment(); @@ -48,6 +50,20 @@ public class RepoPullRequestPagerFragment extends BaseFragment onLoadMore; private PullRequestAdapter adapter; + private RepoPullRequestPagerMvp.View pagerCallback; private RepoPagerMvp.TabsBadgeListener tabsBadgeListener; public static RepoPullRequestFragment newInstance(@NonNull String repoId, @NonNull String login, @NonNull IssueState issueState) { @@ -55,6 +57,11 @@ public class RepoPullRequestFragment extends BaseFragment getLoadMore() { if (onLoadMore == null) { - onLoadMore = new OnLoadMore<>(getPresenter()); + onLoadMore = new OnLoadMore(getPresenter()) { + @Override public void onScrolled(boolean isUp) { + super.onScrolled(isUp); + if (pagerCallback != null) pagerCallback.onScrolled(isUp); + } + }; } onLoadMore.setParameter(getIssueState()); return onLoadMore; diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/PullRequestPagerActivity.java b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/PullRequestPagerActivity.java index c31263d3..eff5221e 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/PullRequestPagerActivity.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/PullRequestPagerActivity.java @@ -30,6 +30,7 @@ import com.fastaccess.helper.AnimHelper; import com.fastaccess.helper.BundleConstant; import com.fastaccess.helper.Bundler; import com.fastaccess.helper.InputHelper; +import com.fastaccess.helper.Logger; import com.fastaccess.helper.PrefGetter; import com.fastaccess.helper.ViewHelper; import com.fastaccess.provider.scheme.LinkParserHelper; @@ -44,6 +45,7 @@ import com.fastaccess.ui.modules.repos.extras.assignees.AssigneesDialogFragment; import com.fastaccess.ui.modules.repos.extras.labels.LabelsDialogFragment; import com.fastaccess.ui.modules.repos.extras.milestone.create.MilestoneDialogFragment; import com.fastaccess.ui.modules.repos.issues.create.CreateIssueActivity; +import com.fastaccess.ui.modules.repos.pull_requests.pull_request.details.files.PullRequestFilesFragment; import com.fastaccess.ui.modules.repos.pull_requests.pull_request.details.timeline.timeline.PullRequestTimelineFragment; import com.fastaccess.ui.modules.repos.pull_requests.pull_request.merge.MergePullRequestDialogFragment; import com.fastaccess.ui.modules.reviews.changes.ReviewChangesActivity; @@ -110,7 +112,6 @@ public class PullRequestPagerActivity extends BaseActivity getNamesToTag() { + PullRequestTimelineFragment fragment = getPullRequestTimelineFragment(); + if (fragment != null) { + return fragment.getNamesToTag(); + } + return new ArrayList<>(); + } + protected void hideAndClearReviews() { - onUpdateTimeline(); getPresenter().getCommitComment().clear(); AnimHelper.mimicFabVisibility(false, prReviewHolder, null); + if (pager == null || pager.getAdapter() == null) return; + PullRequestFilesFragment fragment = (PullRequestFilesFragment) pager.getAdapter().instantiateItem(pager, 2); + if (fragment != null) { + fragment.onRefresh(); + } + } private void addPrReview(@NonNull View view) { @@ -457,9 +480,11 @@ public class PullRequestPagerActivity extends BaseActivity, PullRequestFilesMvp.PatchCallback, - CommentEditorFragment.CommentListener { + CommentEditorFragment.CommentListener, ReviewChangesMvp.ReviewSubmissionCallback { void onSetupIssue(boolean update); diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/files/PullRequestFilesFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/files/PullRequestFilesFragment.java index cd5622b0..cb907c98 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/files/PullRequestFilesFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/files/PullRequestFilesFragment.java @@ -14,6 +14,7 @@ import com.fastaccess.data.dao.CommentRequestModel; import com.fastaccess.data.dao.CommitFileChanges; import com.fastaccess.data.dao.CommitFileModel; import com.fastaccess.data.dao.CommitLinesModel; +import com.fastaccess.data.dao.model.PullRequest; import com.fastaccess.helper.ActivityHelper; import com.fastaccess.helper.BundleConstant; import com.fastaccess.helper.Bundler; @@ -22,7 +23,9 @@ import com.fastaccess.provider.rest.loadmore.OnLoadMore; import com.fastaccess.ui.adapter.CommitFilesAdapter; import com.fastaccess.ui.base.BaseFragment; import com.fastaccess.ui.modules.main.premium.PremiumActivity; +import com.fastaccess.ui.modules.repos.issues.issue.details.IssuePagerMvp; import com.fastaccess.ui.modules.reviews.AddReviewDialogFragment; +import com.fastaccess.ui.widgets.FontTextView; import com.fastaccess.ui.widgets.StateLayout; import com.fastaccess.ui.widgets.recyclerview.DynamicRecyclerView; import com.fastaccess.ui.widgets.recyclerview.scroll.RecyclerViewFastScroller; @@ -45,10 +48,14 @@ public class PullRequestFilesFragment extends BaseFragment toggleMap = new LinkedHashMap<>(); + @BindView(R.id.changes) FontTextView changes; + @BindView(R.id.addition) FontTextView addition; + @BindView(R.id.deletion) FontTextView deletion; private PullRequestFilesMvp.PatchCallback viewCallback; private OnLoadMore onLoadMore; private CommitFilesAdapter adapter; + private IssuePagerMvp.IssuePrCallback issueCallback; public static PullRequestFilesFragment newInstance(@NonNull String repoId, @NonNull String login, long number) { PullRequestFilesFragment view = new PullRequestFilesFragment(); @@ -60,8 +67,16 @@ public class PullRequestFilesFragment extends BaseFragment 0 && item.getLeftLineNo() > 0) { - commentRequestModel.setPosition(item.getPosition()); - } else { - commentRequestModel.setPosition(item.getPosition()); -// commentRequestModel.setLine(item.getRightLineNo() > 0 ? item.getRightLineNo() : item.getLeftLineNo()); - } + commentRequestModel.setPosition(item.getPosition()); if (viewCallback != null) viewCallback.onAddComment(commentRequestModel); + int groupPosition = bundle.getInt(BundleConstant.EXTRA_TWO); + int childPosition = bundle.getInt(BundleConstant.EXTRA_THREE); + CommitFileChanges commitFileChanges = adapter.getItem(groupPosition); + List models = commitFileChanges.getLinesModel(); + if (models != null && !models.isEmpty()) { + CommitLinesModel current = models.get(childPosition); + if (current != null) { + current.setHasCommentedOn(true); + } + models.set(childPosition, current); + adapter.notifyItemChanged(groupPosition); + } } } diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelineFragment.java b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelineFragment.java index 9a0d5994..fe9d2135 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelineFragment.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelineFragment.java @@ -22,6 +22,7 @@ import com.fastaccess.helper.ActivityHelper; import com.fastaccess.helper.BundleConstant; import com.fastaccess.helper.Bundler; import com.fastaccess.provider.rest.loadmore.OnLoadMore; +import com.fastaccess.provider.timeline.CommentsHelper; import com.fastaccess.ui.adapter.IssuesTimelineAdapter; import com.fastaccess.ui.adapter.viewholder.TimelineCommentsViewHolder; import com.fastaccess.ui.base.BaseFragment; @@ -35,6 +36,7 @@ import com.fastaccess.ui.widgets.dialog.MessageDialogView; import com.fastaccess.ui.widgets.recyclerview.DynamicRecyclerView; import com.fastaccess.ui.widgets.recyclerview.scroll.RecyclerViewFastScroller; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -190,7 +192,7 @@ public class PullRequestTimelineFragment extends BaseFragment getNamesToTag() { + return CommentsHelper.getUsersByTimeline(adapter.getData()); } @Override public void showReactionsPopup(@NonNull ReactionTypes type, @NonNull String login, @NonNull String repoId, @@ -283,16 +308,16 @@ public class PullRequestTimelineFragment extends BaseFragment getNamesToTag(); } interface Presenter extends BaseMvp.FAPresenter, BaseViewHolder.OnItemClickListener, diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelinePresenter.java b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelinePresenter.java index b3c91c78..570dafb7 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelinePresenter.java +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/pull_requests/pull_request/details/timeline/timeline/PullRequestTimelinePresenter.java @@ -13,6 +13,7 @@ import com.fastaccess.R; import com.fastaccess.data.dao.CommentRequestModel; import com.fastaccess.data.dao.EditReviewCommentModel; import com.fastaccess.data.dao.GroupedReviewModel; +import com.fastaccess.data.dao.PullRequestStatusModel; import com.fastaccess.data.dao.ReviewCommentModel; import com.fastaccess.data.dao.TimelineModel; import com.fastaccess.data.dao.model.Comment; @@ -20,10 +21,10 @@ import com.fastaccess.data.dao.model.Login; import com.fastaccess.data.dao.model.PullRequest; import com.fastaccess.data.dao.timeline.GenericEvent; import com.fastaccess.data.dao.timeline.SourceModel; +import com.fastaccess.data.dao.types.IssueEventType; import com.fastaccess.data.dao.types.ReactionTypes; import com.fastaccess.helper.ActivityHelper; import com.fastaccess.helper.BundleConstant; -import com.fastaccess.helper.Bundler; import com.fastaccess.helper.InputHelper; import com.fastaccess.provider.rest.RestProvider; import com.fastaccess.provider.scheme.SchemeParser; @@ -52,6 +53,7 @@ public class PullRequestTimelinePresenter extends BasePresenter { @@ -95,17 +98,19 @@ public class PullRequestTimelinePresenter extends BasePresenter { if (getView() == null) return false; @@ -139,15 +144,14 @@ public class PullRequestTimelinePresenter extends BasePresenter { - }); - } else { - EditReviewCommentModel commentModel = bundle.getParcelable(BundleConstant.REVIEW_EXTRA); - if (commentModel != null) { - CommentRequestModel commentRequestModel = new CommentRequestModel(); - commentRequestModel.setBody(text); - commentRequestModel.setInReplyTo(commentModel.getInReplyTo()); - makeRestCall(RestProvider.getReviewService(isEnterprise()) - .submitComment(pullRequest.getLogin(), pullRequest.getRepoId(), pullRequest.getNumber(), commentRequestModel), - reviewCommentModel -> { - sendToView(view -> view.onAddReviewComment(reviewCommentModel, commentModel)); - }); - } + pullRequest.getNumber(), commentRequestModel), comment -> sendToView(view -> view.addComment(TimelineModel.constructComment + (comment)))); } } } @@ -284,7 +276,7 @@ public class PullRequestTimelinePresenter extends BasePresenter { @@ -296,12 +288,7 @@ public class PullRequestTimelinePresenter extends BasePresenter isCollaborator = booleanResponse.code() == 204)); + } setCurrentPage(page); if (parameter.getHead() != null) { - Observable> observable = Observable.zip(RestProvider.getIssueService(isEnterprise()) - .getTimeline(login, repoId, number, page), RestProvider.getReviewService(isEnterprise()) - .getPrReviewComments(login, repoId, number), - RestProvider.getPullRequestService(isEnterprise()).getPullStatus(login, repoId, parameter.getHead().getRef()), + Observable> observable = Observable.zip( + RestProvider.getIssueService(isEnterprise()).getTimeline(login, repoId, number, page), + RestProvider.getReviewService(isEnterprise()).getPrReviewComments(login, repoId, number), + RestProvider.getPullRequestService(isEnterprise()).getPullStatus(login, repoId, parameter.getHead().getSha()) + .onErrorReturn(throwable -> RestProvider.getPullRequestService(isEnterprise()).getPullStatus(login, repoId, + parameter.getBase().getSha()).blockingFirst(new PullRequestStatusModel())), (response, comments, status) -> { if (response != null) { lastPage = response.getLast(); List models = TimelineConverter.INSTANCE.convert(response.getItems(), comments); if (page == 1 && status != null) { - models.add(0, new TimelineModel(status)); + status.setMergable(parameter.isMergeable()); + if (status.getState() != null) models.add(0, new TimelineModel(status)); } return models; } else { diff --git a/app/src/main/java/com/fastaccess/ui/modules/repos/wiki/WikiPresenter.kt b/app/src/main/java/com/fastaccess/ui/modules/repos/wiki/WikiPresenter.kt index 659abc19..d51e3199 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/repos/wiki/WikiPresenter.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/repos/wiki/WikiPresenter.kt @@ -42,37 +42,41 @@ class WikiPresenter : BasePresenter(), WikiMvp.Presenter { private fun getWikiContent(body: String?): Observable { return Observable.fromPublisher { s -> - val document: Document = Jsoup.parse(body, "") - val wikiWrapper = document.select("#wiki-wrapper") - if (wikiWrapper.isNotEmpty()) { - val cloneUrl = wikiWrapper.select(".clone-url") + try { + val document: Document = Jsoup.parse(body, "") + val wikiWrapper = document.select("#wiki-wrapper") + if (wikiWrapper.isNotEmpty()) { + val cloneUrl = wikiWrapper.select(".clone-url") // val bottomRightBar = wikiWrapper.select(".wiki-custom-sidebar") - if (cloneUrl.isNotEmpty()) { - cloneUrl.remove() - } + if (cloneUrl.isNotEmpty()) { + cloneUrl.remove() + } // if (bottomRightBar.isNotEmpty()) { // bottomRightBar.remove() // } - val headerHtml = wikiWrapper.select(".gh-header .gh-header-meta") - val revision = headerHtml.select("a.history") - if (revision.isNotEmpty()) { - revision.remove() - } - val header = "
${headerHtml.html()}
" - val wikiContent = wikiWrapper.select(".wiki-content") - val content = header + wikiContent.select(".wiki-body").html() - val rightBarList = wikiContent.select(".wiki-pages").select("li") - val sidebarList = arrayListOf() - if (rightBarList.isNotEmpty()) { - rightBarList.onEach { - val sidebarTitle = it.select("a").text() - val sidebarLink = it.select("a").attr("href") - sidebarList.add(WikiSideBarModel(sidebarTitle, sidebarLink)) + val headerHtml = wikiWrapper.select(".gh-header .gh-header-meta") + val revision = headerHtml.select("a.history") + if (revision.isNotEmpty()) { + revision.remove() } + val header = "
${headerHtml.html()}
" + val wikiContent = wikiWrapper.select(".wiki-content") + val content = header + wikiContent.select(".wiki-body").html() + val rightBarList = wikiContent.select(".wiki-pages").select("li") + val sidebarList = arrayListOf() + if (rightBarList.isNotEmpty()) { + rightBarList.onEach { + val sidebarTitle = it.select("a").text() + val sidebarLink = it.select("a").attr("href") + sidebarList.add(WikiSideBarModel(sidebarTitle, sidebarLink)) + } + } + s.onNext(WikiContentModel(content, "", sidebarList)) + } else { + s.onNext(WikiContentModel("

No Wiki

", "", arrayListOf())) } - s.onNext(WikiContentModel(content, "", sidebarList)) - } else { - s.onNext(WikiContentModel("

No Wiki

", "", arrayListOf())) + } catch (e: Exception) { + e.printStackTrace() } s.onComplete() } diff --git a/app/src/main/java/com/fastaccess/ui/modules/reviews/AddReviewDialogFragment.kt b/app/src/main/java/com/fastaccess/ui/modules/reviews/AddReviewDialogFragment.kt index 26b716a2..ea599c0d 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/reviews/AddReviewDialogFragment.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/reviews/AddReviewDialogFragment.kt @@ -3,7 +3,6 @@ package com.fastaccess.ui.modules.reviews import android.content.Context import android.graphics.Color import android.os.Bundle -import android.support.design.widget.TextInputLayout import android.support.v4.content.ContextCompat import android.support.v7.widget.Toolbar import android.view.View @@ -18,6 +17,7 @@ import com.fastaccess.helper.ViewHelper import com.fastaccess.ui.base.BaseDialogFragment import com.fastaccess.ui.base.mvp.BaseMvp import com.fastaccess.ui.base.mvp.presenter.BasePresenter +import com.fastaccess.ui.modules.editor.comment.CommentEditorFragment import com.fastaccess.ui.modules.reviews.callback.ReviewCommentListener import com.fastaccess.ui.widgets.SpannableBuilder @@ -29,8 +29,10 @@ class AddReviewDialogFragment : BaseDialogFragment(BundleConstant.ITEM) lineNo.text = SpannableBuilder.builder() .append(if (item.leftLineNo >= 0) String.format("%s.", item.leftLineNo) else "") @@ -75,11 +84,12 @@ class AddReviewDialogFragment : BaseDialogFragment(), ReviewChangesMvp.View { - +class ReviewChangesActivity : BaseDialogFragment(), ReviewChangesMvp.View { @BindView(R.id.toolbar) lateinit var toolbar: Toolbar @BindView(R.id.reviewMethod) lateinit var spinner: Spinner - @BindView(R.id.editText) lateinit var editText: TextInputLayout @State var reviewRequest: ReviewRequestModel? = null @State var repoId: String? = null @State var owner: String? = null @State var number: Long? = null - @State var isProgressShowing: Boolean = false @State var isClosed: Boolean = false + @State var isAuthor: Boolean = false - override fun layout(): Int = R.layout.add_review_dialog_layout + private var subimssionCallback: ReviewChangesMvp.ReviewSubmissionCallback? = null - override fun isTransparent(): Boolean = true + private val commentEditorFragment: CommentEditorFragment? by lazy { + childFragmentManager.findFragmentByTag("commentContainer") as CommentEditorFragment? + } - override fun canBack(): Boolean = true + override fun onAttach(context: Context?) { + super.onAttach(context) + if (parentFragment is ReviewChangesMvp.ReviewSubmissionCallback) { + subimssionCallback = parentFragment as ReviewChangesMvp.ReviewSubmissionCallback + } else if (context is ReviewChangesMvp.ReviewSubmissionCallback) { + subimssionCallback = context + } + } - override fun isSecured(): Boolean = false + override fun onDetach() { + subimssionCallback = null + super.onDetach() + } override fun providePresenter(): ReviewChangesPresenter = ReviewChangesPresenter() - override fun onCreate(savedInstanceState: Bundle?) { - ThemeEngine.applyDialogTheme(this) - super.onCreate(savedInstanceState) - setToolbarIcon(R.drawable.ic_clear) - val bundle = intent.extras!! - reviewRequest = bundle.getParcelable(BundleConstant.EXTRA) - repoId = bundle.getString(BundleConstant.EXTRA_TWO) - owner = bundle.getString(BundleConstant.EXTRA_THREE) - number = bundle.getLong(BundleConstant.ID) - isClosed = bundle.getBoolean(BundleConstant.EXTRA_FIVE) - val isAuthor = bundle.getBoolean(BundleConstant.EXTRA_FOUR) + override fun fragmentLayout(): Int = R.layout.add_review_dialog_layout + + override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + val fragment = CommentEditorFragment() + fragment.arguments = Bundler.start().put(BundleConstant.YES_NO_EXTRA, true).end() + childFragmentManager.beginTransaction() + .replace(R.id.commentContainer, fragment, "commentContainer") + .commit() + val bundle = arguments!! + reviewRequest = bundle.getParcelable(BundleConstant.EXTRA) + repoId = bundle.getString(BundleConstant.EXTRA_TWO) + owner = bundle.getString(BundleConstant.EXTRA_THREE) + number = bundle.getLong(BundleConstant.ID) + isClosed = bundle.getBoolean(BundleConstant.EXTRA_FIVE) + isAuthor = bundle.getBoolean(BundleConstant.EXTRA_FOUR) + } + toolbar.navigationIcon = ContextCompat.getDrawable(context, R.drawable.ic_clear) + toolbar.inflateMenu(R.menu.done_menu) + toolbar.setNavigationOnClickListener { dismiss() } + toolbar.setOnMenuItemClickListener { + if (it.itemId == R.id.submit) { + if (spinner.selectedItemPosition != 0 && commentEditorFragment?.getEditText()?.text.isNullOrEmpty()) { + commentEditorFragment?.getEditText()?.error = getString(R.string.required_field) + } else { + commentEditorFragment?.getEditText()?.error = null + presenter.onSubmit(reviewRequest!!, repoId!!, owner!!, number!!, InputHelper.toString(commentEditorFragment?.getEditText()?.text) + , spinner.selectedItem as String) + } + } + return@setOnMenuItemClickListener true + } + if (isAuthor || isClosed) { spinner.setSelection(2, true) spinner.isEnabled = false } } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.done_menu, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.submit -> { - if (spinner.selectedItemPosition != 0 && editText.editText?.text.isNullOrEmpty()) { - editText.error = getString(R.string.required_field) - } else { - editText.error = null - presenter.onSubmit(reviewRequest!!, repoId!!, owner!!, number!!, InputHelper.toString(editText), spinner.selectedItem as String) - } - return true - } - else -> { - return super.onOptionsItemSelected(item) - } - } - } - override fun onSuccessfullySubmitted() { - setResult(Activity.RESULT_OK) - finish() + hideProgress() + subimssionCallback?.onSuccessfullyReviewed() + dismiss() } override fun onErrorSubmitting() { showErrorMessage(getString(R.string.network_error)) } - override fun showProgress(@StringRes resId: Int) { - var msg = getString(R.string.in_progress) - if (resId != 0) { - msg = getString(resId) - } - if (!isProgressShowing && !isFinishing) { - var fragment = AppHelper.getFragmentByTag(supportFragmentManager, - ProgressDialogFragment.TAG) as ProgressDialogFragment? - if (fragment == null) { - isProgressShowing = true - fragment = ProgressDialogFragment.newInstance(msg, false) - fragment.show(supportFragmentManager, ProgressDialogFragment.TAG) - } - } - } - - override fun hideProgress() { - val fragment = AppHelper.getFragmentByTag(supportFragmentManager, ProgressDialogFragment.TAG) as ProgressDialogFragment? - if (fragment != null) { - isProgressShowing = false - fragment.dismiss() - } - } - override fun showMessage(titleRes: Int, msgRes: Int) { hideProgress() super.showMessage(titleRes, msgRes) @@ -133,9 +117,26 @@ class ReviewChangesActivity : BaseActivity? { + return arrayListOf() + } + companion object { - fun startForResult(activity: Activity, view: View, reviewChanges: ReviewRequestModel, repoId: String, owner: String, number: Long, - isAuthor: Boolean, isEnterprise: Boolean, isClosed: Boolean) { + fun startForResult(reviewChanges: ReviewRequestModel, repoId: String, owner: String, number: Long, + isAuthor: Boolean, isEnterprise: Boolean, isClosed: Boolean): ReviewChangesActivity { + val fragment = ReviewChangesActivity() val bundle = Bundler.start() .put(BundleConstant.EXTRA, reviewChanges) .put(BundleConstant.EXTRA_TWO, repoId) @@ -145,9 +146,8 @@ class ReviewChangesActivity : BaseActivity(), ReviewCha } else { sendToView { it.onErrorSubmitting() } } - }) + }, false) } } \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/modules/settings/SlackBottomSheetDialog.java b/app/src/main/java/com/fastaccess/ui/modules/settings/SlackBottomSheetDialog.java index 91cf9aed..e999d0ca 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/settings/SlackBottomSheetDialog.java +++ b/app/src/main/java/com/fastaccess/ui/modules/settings/SlackBottomSheetDialog.java @@ -51,7 +51,7 @@ public class SlackBottomSheetDialog extends BaseBottomSheetDialog { @OnClick({R.id.cancel, R.id.ok}) public void onViewClicked(View view) { switch (view.getId()) { case R.id.ok: - ActivityHelper.startCustomTab(getActivity(), "http://rebrand.ly/fasthub-slack"); + ActivityHelper.startCustomTab(getActivity(), "http://rebrand.ly/fasthub"); break; } if (listener != null) listener.onDismissed(); diff --git a/app/src/main/java/com/fastaccess/ui/modules/theme/ThemeActivity.kt b/app/src/main/java/com/fastaccess/ui/modules/theme/ThemeActivity.kt index 7f776c80..aade5618 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/theme/ThemeActivity.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/theme/ThemeActivity.kt @@ -7,6 +7,7 @@ import android.os.Bundle import android.view.View import android.view.ViewAnimationUtils import butterknife.BindView +import butterknife.OnClick import com.fastaccess.R import com.fastaccess.data.dao.FragmentPagerAdapterModel import com.fastaccess.helper.PrefGetter @@ -14,6 +15,7 @@ import com.fastaccess.ui.adapter.FragmentsPagerAdapter import com.fastaccess.ui.base.BaseActivity import com.fastaccess.ui.base.mvp.BaseMvp import com.fastaccess.ui.base.mvp.presenter.BasePresenter +import com.fastaccess.ui.modules.main.premium.PremiumActivity import com.fastaccess.ui.modules.theme.fragment.ThemeFragmentMvp import com.fastaccess.ui.widgets.CardsPagerTransformerBasic import com.fastaccess.ui.widgets.ViewPagerView @@ -28,8 +30,11 @@ class ThemeActivity : BaseActivity @BindView(R.id.pager) lateinit var pager: ViewPagerView @BindView(R.id.parentLayout) lateinit var parentLayout: View - override fun layout(): Int = R.layout.theme_viewpager + @OnClick(R.id.premium) fun onOpenPremium() { + PremiumActivity.startActivity(this) + } + override fun layout(): Int = R.layout.theme_viewpager override fun isTransparent(): Boolean = false diff --git a/app/src/main/java/com/fastaccess/ui/modules/theme/fragment/ThemeFragment.kt b/app/src/main/java/com/fastaccess/ui/modules/theme/fragment/ThemeFragment.kt index dfe09b87..39a3325d 100644 --- a/app/src/main/java/com/fastaccess/ui/modules/theme/fragment/ThemeFragment.kt +++ b/app/src/main/java/com/fastaccess/ui/modules/theme/fragment/ThemeFragment.kt @@ -11,7 +11,6 @@ import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ProgressBar import butterknife.BindView import butterknife.ButterKnife import butterknife.Unbinder @@ -19,6 +18,7 @@ import com.fastaccess.R import com.fastaccess.helper.* import com.fastaccess.ui.base.BaseFragment import com.fastaccess.ui.modules.main.donation.DonateActivity +import com.fastaccess.ui.modules.main.premium.PremiumActivity import com.fastaccess.ui.widgets.SpannableBuilder /** @@ -113,6 +113,7 @@ class ThemeFragment : BaseFragment setTheme(getString(R.string.dark_theme_mode)) R.style.ThemeAmlod -> applyAmlodTheme() R.style.ThemeBluish -> applyBluishTheme() + R.style.ThemeMidnight -> applyMidnightTheme() } } @@ -134,6 +135,15 @@ class ThemeFragment : BaseFragment(), Tr val bundle = intent.extras if (bundle != null) { val lang: String = bundle.getString(BundleConstant.EXTRA) - val query: String = bundle.getString(BundleConstant.EXTRA_TWO) + val query: String? = bundle.getString(BundleConstant.EXTRA_TWO) if (!lang.isEmpty()) { selectedTitle = lang } - if (!query.isEmpty()) { - when (query.toLowerCase()) { + if (query.isNullOrEmpty()) { + daily.isSelected = true + } else { + when (query?.toLowerCase()) { "daily" -> daily.isSelected = true "weekly" -> weekly.isSelected = true "monthly" -> monthly.isSelected = true } - } else { - daily.isSelected = true } } else { daily.isSelected = true diff --git a/app/src/main/java/com/fastaccess/ui/widgets/StateLayout.java b/app/src/main/java/com/fastaccess/ui/widgets/StateLayout.java index fbfbd6e1..7a126f42 100644 --- a/app/src/main/java/com/fastaccess/ui/widgets/StateLayout.java +++ b/app/src/main/java/com/fastaccess/ui/widgets/StateLayout.java @@ -95,8 +95,8 @@ public class StateLayout extends NestedScrollView { } public void setEmptyText(@NonNull String text) { - this.emptyTextValue = text; - emptyText.setText(text); + this.emptyTextValue = text + "\n\n¯\\_(ツ)_/¯"; + emptyText.setText(emptyTextValue); } public void showEmptyState() { diff --git a/app/src/main/java/com/fastaccess/ui/widgets/dialog/ProgressDialogFragment.java b/app/src/main/java/com/fastaccess/ui/widgets/dialog/ProgressDialogFragment.java index 1e2a70ed..8a1222d6 100644 --- a/app/src/main/java/com/fastaccess/ui/widgets/dialog/ProgressDialogFragment.java +++ b/app/src/main/java/com/fastaccess/ui/widgets/dialog/ProgressDialogFragment.java @@ -10,6 +10,7 @@ import android.support.v4.app.DialogFragment; import com.fastaccess.helper.AnimHelper; import com.fastaccess.helper.Bundler; +import com.fastaccess.helper.PrefGetter; /** * Created by Kosh on 09 Dec 2016, 5:18 PM @@ -39,7 +40,8 @@ public class ProgressDialogFragment extends DialogFragment { progressDialog.setCancelable(isCancelable); setCancelable(isCancelable); if (getActivity() != null && !getActivity().isFinishing()) { - progressDialog.setOnShowListener(dialogInterface -> AnimHelper.revealDialog(progressDialog, 200)); + if (!PrefGetter.isAppAnimationDisabled()) + progressDialog.setOnShowListener(dialogInterface -> AnimHelper.revealDialog(progressDialog, 200)); } return progressDialog; } diff --git a/app/src/main/java/com/fastaccess/ui/widgets/markdown/MarkDownLayout.kt b/app/src/main/java/com/fastaccess/ui/widgets/markdown/MarkDownLayout.kt index 92aa472f..b3715652 100644 --- a/app/src/main/java/com/fastaccess/ui/widgets/markdown/MarkDownLayout.kt +++ b/app/src/main/java/com/fastaccess/ui/widgets/markdown/MarkDownLayout.kt @@ -35,6 +35,7 @@ class MarkDownLayout : LinearLayout { var markdownListener: MarkdownListener? = null @BindView(R.id.editorIconsHolder) lateinit var editorIconsHolder: HorizontalScrollView + @BindView(R.id.addEmoji) lateinit var addEmojiView: View constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) @@ -61,12 +62,14 @@ class MarkDownLayout : LinearLayout { editText.isEnabled = false MarkDownProvider.setMdText(editText, InputHelper.toString(editText)) editorIconsHolder.visibility = View.INVISIBLE + addEmojiView.visibility = View.INVISIBLE ViewHelper.hideKeyboard(editText) } else { editText.setText(it.getSavedText()) editText.setSelection(editText.text.length) editText.isEnabled = true editorIconsHolder.visibility = View.VISIBLE + addEmojiView.visibility = View.VISIBLE ViewHelper.showKeyboard(editText) } } @@ -84,8 +87,10 @@ class MarkDownLayout : LinearLayout { Snackbar.make(this, R.string.error_highlighting_editor, Snackbar.LENGTH_SHORT).show() } else { when { - v.id == R.id.link -> EditorLinkImageDialogFragment.newInstance(true).show(it.fragmentManager(), "BannerDialogFragment") - v.id == R.id.image -> EditorLinkImageDialogFragment.newInstance(false).show(it.fragmentManager(), "BannerDialogFragment") + v.id == R.id.link -> EditorLinkImageDialogFragment.newInstance(true) + .show(it.fragmentManager(), "EditorLinkImageDialogFragment") + v.id == R.id.image -> EditorLinkImageDialogFragment.newInstance(false) + .show(it.fragmentManager(), "EditorLinkImageDialogFragment") v.id == R.id.addEmoji -> { ViewHelper.hideKeyboard(it.getEditText()) EmojiBottomSheet().show(it.fragmentManager(), "EmojiBottomSheet") @@ -126,6 +131,8 @@ class MarkDownLayout : LinearLayout { } else { it.setText(it.text.toString().replace(sentFromFastHub, "")) } + editText.setSelection(it.text.length) + editText.requestFocus() } } } @@ -135,12 +142,7 @@ class MarkDownLayout : LinearLayout { markdownListener?.getEditText()?.let { editText -> ViewHelper.showKeyboard(editText) emoji?.let { - editText.setText(if (editText.text.isNullOrEmpty()) { - ":${it.aliases[0]}:" - } else { - "${editText.text} :${it.aliases[0]}:" - }) - editText.setSelection(editText.text.length) + MarkDownProvider.insertAtCursor(editText, ":${it.aliases[0]}:") } } } @@ -150,4 +152,14 @@ class MarkDownLayout : LinearLayout { fun fragmentManager(): FragmentManager fun getSavedText(): CharSequence? } + + fun onAppendLink(title: String?, link: String?, isLink: Boolean) { + markdownListener?.let { + if (isLink) { + MarkDownProvider.addLink(it.getEditText(), InputHelper.toString(title), InputHelper.toString(link)) + } else { + MarkDownProvider.addPhoto(it.getEditText(), InputHelper.toString(title), InputHelper.toString(link)) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/DynamicRecyclerView.java b/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/DynamicRecyclerView.java index 27f3de75..f110d1fa 100644 --- a/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/DynamicRecyclerView.java +++ b/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/DynamicRecyclerView.java @@ -64,6 +64,7 @@ public class DynamicRecyclerView extends RecyclerView { } } + public void removeBottomDecoration() { if (bottomPaddingDecoration != null) { removeItemDecoration(bottomPaddingDecoration); diff --git a/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/InfiniteScroll.java b/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/InfiniteScroll.java index d3e5e87e..7a55a496 100644 --- a/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/InfiniteScroll.java +++ b/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/InfiniteScroll.java @@ -48,6 +48,7 @@ public abstract class InfiniteScroll extends RecyclerView.OnScrollListener { newlyAdded = false; return; } + onScrolled(dy > 0); if (layoutManager == null) { initLayoutManager(recyclerView.getLayoutManager()); } @@ -103,5 +104,7 @@ public abstract class InfiniteScroll extends RecyclerView.OnScrollListener { public abstract boolean onLoadMore(int page, int totalItemsCount); + public void onScrolled(boolean isUp) {} + } diff --git a/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/RecyclerViewFastScroller.java b/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/RecyclerViewFastScroller.java index e7c246d8..ca41c086 100755 --- a/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/RecyclerViewFastScroller.java +++ b/app/src/main/java/com/fastaccess/ui/widgets/recyclerview/scroll/RecyclerViewFastScroller.java @@ -229,6 +229,8 @@ public class RecyclerViewFastScroller extends FrameLayout { protected void hideShow() { if (recyclerView != null && recyclerView.getAdapter() != null) { setVisibility(recyclerView.getAdapter().getItemCount() > 10 ? VISIBLE : GONE); + } else { + setVisibility(GONE); } } } \ No newline at end of file diff --git a/app/src/main/java/com/prettifier/pretty/PrettifyWebView.java b/app/src/main/java/com/prettifier/pretty/PrettifyWebView.java index 1b79bcdf..bd9f6900 100644 --- a/app/src/main/java/com/prettifier/pretty/PrettifyWebView.java +++ b/app/src/main/java/com/prettifier/pretty/PrettifyWebView.java @@ -19,6 +19,7 @@ import android.webkit.WebViewClient; import com.fastaccess.R; import com.fastaccess.helper.AppHelper; import com.fastaccess.helper.InputHelper; +import com.fastaccess.helper.Logger; import com.fastaccess.helper.ViewHelper; import com.fastaccess.provider.markdown.MarkDownProvider; import com.fastaccess.provider.scheme.SchemeParser; @@ -195,15 +196,21 @@ public class PrettifyWebView extends NestedWebView { post(() -> loadDataWithBaseURL("file:///android_asset/md/", page, "text/html", "utf-8", null)); } - public void loadImage(@NonNull String url) { + public void loadImage(@NonNull String url, boolean isSvg) { WebSettings settings = getSettings(); settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.SINGLE_COLUMN); setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY); settings.setSupportZoom(true); settings.setBuiltInZoomControls(true); settings.setDisplayZoomControls(false); - String html = ""; + String html; + if (isSvg) { + html = url; + } else { + html = "" + + ""; + } + Logger.e(html); loadData(html, "text/html", null); } diff --git a/app/src/main/java/com/prettifier/pretty/helper/GithubHelper.java b/app/src/main/java/com/prettifier/pretty/helper/GithubHelper.java index f341da43..a88bac09 100644 --- a/app/src/main/java/com/prettifier/pretty/helper/GithubHelper.java +++ b/app/src/main/java/com/prettifier/pretty/helper/GithubHelper.java @@ -23,7 +23,8 @@ import java.util.ArrayList; public class GithubHelper { @NonNull public static String generateContent(@NonNull Context context, @NonNull String source, - @Nullable String baseUrl, boolean dark, boolean isWiki, boolean replace) { + @Nullable String baseUrl, boolean dark, + boolean isWiki, boolean replace) { if (baseUrl == null) { return mergeContent(context, source, dark, replace); } else { diff --git a/app/src/main/res/drawable/fastscroller_bubble.xml b/app/src/main/res/drawable/fastscroller_bubble.xml index 611c1571..30d3322f 100755 --- a/app/src/main/res/drawable/fastscroller_bubble.xml +++ b/app/src/main/res/drawable/fastscroller_bubble.xml @@ -3,7 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android"> - + diff --git a/app/src/main/res/drawable/ic_project.xml b/app/src/main/res/drawable/ic_project.xml new file mode 100644 index 00000000..75d0dde7 --- /dev/null +++ b/app/src/main/res/drawable/ic_project.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layouts/main_layouts/layout-land/repo_file_header_layout.xml b/app/src/main/res/layouts/main_layouts/layout-land/repo_file_header_layout.xml index 12abc715..d624a675 100644 --- a/app/src/main/res/layouts/main_layouts/layout-land/repo_file_header_layout.xml +++ b/app/src/main/res/layouts/main_layouts/layout-land/repo_file_header_layout.xml @@ -83,6 +83,19 @@ android:padding="@dimen/spacing_micro" android:src="@drawable/ic_download"/> + + + + + + + android:visibility="gone"/> - - + android:layout_margin="@dimen/spacing_xs_large" + android:entries="@array/review_methods"/> - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/comment_box_layout.xml b/app/src/main/res/layouts/main_layouts/layout/comment_box_layout.xml index c8c131f2..ee8c2667 100644 --- a/app/src/main/res/layouts/main_layouts/layout/comment_box_layout.xml +++ b/app/src/main/res/layouts/main_layouts/layout/comment_box_layout.xml @@ -8,15 +8,11 @@ android:background="?colorPrimary" android:orientation="vertical"> - - diff --git a/app/src/main/res/layouts/main_layouts/layout/delete_repo_file_layout.xml b/app/src/main/res/layouts/main_layouts/layout/delete_repo_file_layout.xml new file mode 100644 index 00000000..fd278f40 --- /dev/null +++ b/app/src/main/res/layouts/main_layouts/layout/delete_repo_file_layout.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/edit_repo_file_layout.xml b/app/src/main/res/layouts/main_layouts/layout/edit_repo_file_layout.xml new file mode 100644 index 00000000..7b8e8bcd --- /dev/null +++ b/app/src/main/res/layouts/main_layouts/layout/edit_repo_file_layout.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/issue_pager_activity.xml b/app/src/main/res/layouts/main_layouts/layout/issue_pager_activity.xml index 24b0fd93..dc553a76 100644 --- a/app/src/main/res/layouts/main_layouts/layout/issue_pager_activity.xml +++ b/app/src/main/res/layouts/main_layouts/layout/issue_pager_activity.xml @@ -8,7 +8,7 @@ android:layout_height="match_parent" android:fitsSystemWindows="true"> - + android:layout_height="match_parent" + android:layout_above="@+id/commentFragment"> - - + diff --git a/app/src/main/res/layouts/main_layouts/layout/issue_popup_layout.xml b/app/src/main/res/layouts/main_layouts/layout/issue_popup_layout.xml index 70306806..09ebef37 100644 --- a/app/src/main/res/layouts/main_layouts/layout/issue_popup_layout.xml +++ b/app/src/main/res/layouts/main_layouts/layout/issue_popup_layout.xml @@ -116,8 +116,6 @@ style="@style/TextAppearance.AppCompat.Medium" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingBottom="@dimen/spacing_normal" - android:paddingTop="@dimen/spacing_normal" tools:text="Username"/> @@ -154,8 +152,6 @@ style="@style/TextAppearance.AppCompat.Medium" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingBottom="@dimen/spacing_normal" - android:paddingTop="@dimen/spacing_normal" tools:text="Label 1"/> @@ -206,8 +202,6 @@ style="@style/TextAppearance.AppCompat.Subhead" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingBottom="@dimen/spacing_normal" - android:paddingTop="@dimen/spacing_normal" tools:text="Label 1"/> diff --git a/app/src/main/res/layouts/main_layouts/layout/pro_features_layout.xml b/app/src/main/res/layouts/main_layouts/layout/pro_features_layout.xml index 879cee18..03f16f36 100644 --- a/app/src/main/res/layouts/main_layouts/layout/pro_features_layout.xml +++ b/app/src/main/res/layouts/main_layouts/layout/pro_features_layout.xml @@ -2,6 +2,8 @@ @@ -274,4 +276,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/project_columns_layout.xml b/app/src/main/res/layouts/main_layouts/layout/project_columns_layout.xml new file mode 100644 index 00000000..22c30db4 --- /dev/null +++ b/app/src/main/res/layouts/main_layouts/layout/project_columns_layout.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/projects_activity_layout.xml b/app/src/main/res/layouts/main_layouts/layout/projects_activity_layout.xml new file mode 100644 index 00000000..848b5831 --- /dev/null +++ b/app/src/main/res/layouts/main_layouts/layout/projects_activity_layout.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/pull_request_files_layout.xml b/app/src/main/res/layouts/main_layouts/layout/pull_request_files_layout.xml new file mode 100644 index 00000000..de055b8e --- /dev/null +++ b/app/src/main/res/layouts/main_layouts/layout/pull_request_files_layout.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/repo_file_header_layout.xml b/app/src/main/res/layouts/main_layouts/layout/repo_file_header_layout.xml index d35bf4bc..e8b6ba28 100644 --- a/app/src/main/res/layouts/main_layouts/layout/repo_file_header_layout.xml +++ b/app/src/main/res/layouts/main_layouts/layout/repo_file_header_layout.xml @@ -48,6 +48,7 @@ android:layout_height="wrap_content" android:orientation="horizontal"> + + + - - - - + android:layout_height="wrap_content"/> \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/search_layout.xml b/app/src/main/res/layouts/main_layouts/layout/search_layout.xml index 1c406f4e..041aef20 100644 --- a/app/src/main/res/layouts/main_layouts/layout/search_layout.xml +++ b/app/src/main/res/layouts/main_layouts/layout/search_layout.xml @@ -57,7 +57,7 @@ android:contentDescription="@string/clear" android:padding="@dimen/spacing_micro" android:src="@drawable/ic_clear" - android:visibility="invisible"/> + android:visibility="gone"/> - @@ -9,7 +10,20 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/main_layouts/layout/trending_activity_layout.xml b/app/src/main/res/layouts/main_layouts/layout/trending_activity_layout.xml index 30b0d6ad..cc88799c 100644 --- a/app/src/main/res/layouts/main_layouts/layout/trending_activity_layout.xml +++ b/app/src/main/res/layouts/main_layouts/layout/trending_activity_layout.xml @@ -139,7 +139,7 @@ android:padding="@dimen/spacing_micro" android:scaleType="centerCrop" android:src="@drawable/ic_clear" - android:visibility="invisible" + android:visibility="gone" tools:visibility="visible"/> diff --git a/app/src/main/res/layouts/row_layouts/layout/column_card_row_layout.xml b/app/src/main/res/layouts/row_layouts/layout/column_card_row_layout.xml new file mode 100644 index 00000000..41b07da4 --- /dev/null +++ b/app/src/main/res/layouts/row_layouts/layout/column_card_row_layout.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/row_layouts/layout/comments_row_item.xml b/app/src/main/res/layouts/row_layouts/layout/comments_row_item.xml index d0bd5ede..2414ebf7 100644 --- a/app/src/main/res/layouts/row_layouts/layout/comments_row_item.xml +++ b/app/src/main/res/layouts/row_layouts/layout/comments_row_item.xml @@ -119,7 +119,7 @@ - - - - - - - + android:orientation="horizontal"> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layouts/row_layouts/layout/file_path_row_item.xml b/app/src/main/res/layouts/row_layouts/layout/file_path_row_item.xml index ae73d7f7..e25e1747 100644 --- a/app/src/main/res/layouts/row_layouts/layout/file_path_row_item.xml +++ b/app/src/main/res/layouts/row_layouts/layout/file_path_row_item.xml @@ -1,13 +1,15 @@ \ No newline at end of file diff --git a/app/src/main/res/layouts/row_layouts/layout/issue_timeline_row_item.xml b/app/src/main/res/layouts/row_layouts/layout/issue_timeline_row_item.xml index 4d0773a9..75cb6b0d 100644 --- a/app/src/main/res/layouts/row_layouts/layout/issue_timeline_row_item.xml +++ b/app/src/main/res/layouts/row_layouts/layout/issue_timeline_row_item.xml @@ -64,7 +64,6 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="top" - android:layout_marginEnd="@dimen/avatar_margin_end" android:layout_marginStart="@dimen/avatar_margin"/> diff --git a/app/src/main/res/layouts/row_layouts/layout/profile_overview_layout.xml b/app/src/main/res/layouts/row_layouts/layout/profile_overview_layout.xml index 238c8511..fb847e31 100644 --- a/app/src/main/res/layouts/row_layouts/layout/profile_overview_layout.xml +++ b/app/src/main/res/layouts/row_layouts/layout/profile_overview_layout.xml @@ -119,55 +119,54 @@ android:paddingTop="@dimen/spacing_normal" tools:text="What’s the secret to large and cold peanut butter? Always use quartered szechuan pepper."/> - + android:orientation="horizontal"> - + android:layout_weight="1" + android:ellipsize="end" + android:maxLines="1" + android:textAppearance="@style/TextAppearance.AppCompat.Caption" + android:textColor="@color/white" + tools:text="Following (40)"/> + + - - - + diff --git a/app/src/main/res/menu-sw600dp/repo_with_project_bottom_nav_menu.xml b/app/src/main/res/menu-sw600dp/repo_with_project_bottom_nav_menu.xml new file mode 100644 index 00000000..424d09d5 --- /dev/null +++ b/app/src/main/res/menu-sw600dp/repo_with_project_bottom_nav_menu.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/download_browser_menu.xml b/app/src/main/res/menu/download_browser_menu.xml index f5224de1..1c6b5db7 100644 --- a/app/src/main/res/menu/download_browser_menu.xml +++ b/app/src/main/res/menu/download_browser_menu.xml @@ -3,6 +3,10 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + + - + + + + android:icon="@drawable/ic_project" + android:title="@string/projects"/> \ No newline at end of file diff --git a/app/src/main/res/menu/share_menu.xml b/app/src/main/res/menu/share_menu.xml index b66cc86d..8e417946 100644 --- a/app/src/main/res/menu/share_menu.xml +++ b/app/src/main/res/menu/share_menu.xml @@ -22,4 +22,12 @@ android:visible="false" app:showAsAction="never"/> + + + \ No newline at end of file diff --git a/app/src/main/res/raw/changelog.html b/app/src/main/res/raw/changelog.html index fd88f5be..f0bc5306 100644 --- a/app/src/main/res/raw/changelog.html +++ b/app/src/main/res/raw/changelog.html @@ -1,108 +1,69 @@ -

FastHub changelog -

-

Version 4.0.3 (Multiple Accounts, Enterprise & PR changes) -

-
-

Thanks to - @passsy & his company - @grandcentrix - for providing me with an Enterprise account in their server to ease implementing Enterprise support in   FastHub. -

-
-
-

Thanks to - cookicons - for creating - FastHub - new icon. -

-
-
-

Thanks to - @dedepete - for helping out with CI & configuring nightly builds for - FastHub -

-
-

Bugs , Enhancements & new Features (4.0.3 & 4.0.0) -

-
    -
  • (4.0.3 Fix) Disabled tinting in light theme
  • -
  • (4.0.3 Fix) Fixed some notification crashes on some devices
  • -
  • (4.0.3 Fix) Updated app shortcuts icons (thanks @lucasvalenteds)
  • -
  • (4.0.3 Fix) Ordering langauge locales
  • -
  • (4.0.3 Fix) Attempt to fix a crash on some devices when browsing large number of Commit files
  • -
  • (4.0.3 New) Added copy URL to commit
  • -
  • (4.0.2 New) Added a way to disable navigation bar tinitng in different themes (Settings/Customization)
  • -
  • (4.0.2 Fix) Fixed clicking on notification might crash on some devices
  • -
  • (4.0.2 Fix) Fixed app shortcut icons to follow material design guidelines
  • -
  • (4.0.2 Fix) Fixed Traditional & Simplified Chinese langauge locale
  • -
  • (4.0.2 Fix) This release also has an attempt to fix Google Play Service crash when installing - FastHub -
  • -
  • (4.0.2 Fix) This release fixes app hangs on devices that don't have Google Play Service installed.
  • -
  • (4.0.1 Fix) Pinned repos crashes
  • -
  • (4.0.1 Fix) Multiple Accounts (if enterprise account login is same as GitHub) thanks   - @passsy -
  • -
  • (New) Multiple Accounts
  • -
  • (New) Enterprise support
  • -
  • (New) PR review changes & on-line-number reviews
  • -
  • (New) Support to all merge strategies (Squash & Rebase)
  • -
  • (New) Comment on Commit file changes (on Code line number)
  • -
  • (New) Revamp of Branches & Tags, now its easy to differentiate between them
  • -
  • (New) Clicking on multiple Commits now will ask you which one to open.
  • -
  • (New) Now you can share links to FastHub from external Apps
  • -
  • (New) Showing owner & original poster in comments
  • -
  • (New) Custom code color palette
  • -
  • (New) Clicking on License should open its corresponding file
  • -
  • (New) Comment links are now copy-able thanks to (@eygraber)
  • -
  • (New) Pinned repos are now smart to sort your Pinned Repos by itself.
  • -
  • (New) Access your Repos from Menu Drawer
  • -
  • (New) Access your Starred Repos from Menu Drawer
  • -
  • (New) Showing the top most 5 accessed Pinned Repos in Menu Drawer
  • -
  • (New) - FastHub - now has Nightly builds thanks (@dedepete) -
  • -
  • (New) Disabling Issues Tab if Issues disabled in Repo.
  • -
  • (New) Added Share to Users & Organizations profiles
  • -
  • (New) Czech language thanks to (@hejsekvojtech)
  • -
  • (New) Spanish language thanks to (@alete)
  • -
  • (Enhancement) Opening FastHub from other Apps should open FastHub in new document thanks to (@eygraber)
  • -
  • (Enhancement) Wiki links
  • -
  • (Enhancement) Issue & PRs grammar
  • -
  • (Enhancement) Overall app layouts enhancements.
  • -
  • (Fix) Opening Submodule.
  • -
  • (Fix) Trending Language & today's stars.
  • -
  • (Fix) Code wrapping.
  • -
  • (Fix) PRs/Issues where the assigned user was a Team.
  • -
  • (Fix) Added back support for Local DB in some places
  • -
  • (Fix) Added Pagination to Branches & Tags.
  • -
  • (Fix) Traditional & Simplified Chinese language selector.
  • -
  • (Fix) Deep markdown images relative paths.
  • -
  • (Fix) Brought back fully zoom-out in code.
  • -
  • (Fix) More deeper file links parsing
  • -
  • (Fix) First Comment edited date.
  • -
  • (Fix) All notifications refresh isn’t disappeared.
  • -
  • (Fix) Readme progressbar
  • -
  • (Fix) Some crashes from the crash report.
  • -
  • (Fix) Lots of bug fixes
  • -
  • There are more stuff are not mentioned, find them out :p
  • -
-
-

P.S: FastHub is still in development mode, things will eventually come, rating FastHub 1 or 5 stars in Play store to request a new - feature or to report an issue they’ll be ignored, the best place to report issues/FRs are in GitHub issue ticket, you could go to - About & click on - Report Issue - and the issue will be posted directly to - FastHub - repo. -

-
-
-

Thanks to everyone who contributed either via reporting bugs or via code contribution

-
-

- Thank you very much -

\ No newline at end of file + + + + + Untitled Document.md + + + +

FastHub changelog +

+

Version 4.2.0 (Create, Edit & Delete files (make Commits)) +

+
+

Reporting Issues or Feature Requests in Google Play review section, will be ignored or might even get your account to be blocked from + FastHub. You are using an app for GitHub, which provides a proper way to report issues. +
+ Please report the issues in FastHub repo instead, by opening the Drawer Menu, click about and click “Report an Issue”PLEASE + USE IT. +

+
+

Bugs , Enhancements & new Features (3.2.0) +

+
    +
  • (New) Make commits on repos from FastHub (this only applies for repo owners so far). + PRO +
  • +
  • (New) Long pressing files/directories will open up their Commit/Git History.
  • +
  • (New) Indicates PR review line has a comment.
  • +
  • (New) Showing number of addition, deletion & files in PR files tab.
  • +
  • (New) Untoggle MD rendering to see their actual code.
  • +
  • (New) Settings option to disable Animations in FastHub.
  • +
  • (New) Copy SHA from Commit.
  • +
  • (New) Forks, Watchers & Stargazers links handling.
  • +
  • (Enhancement) Loading larger number of comments per page.
  • +
  • (Enhancement) Hide fab while scrolling in Issues & PR tab.
  • +
  • (Fix) PR Status where it got somehow broken in previous release.
  • +
  • (Fix) A text under PR status will indicates if the PR is mergable or not.
  • +
  • (Fix) Tagging users in full screen editor.
  • +
  • (Fix) Comments text selection where sometime they aren’t selectable.
  • +
  • (Fix) PR Review footer is invisible in (4.1.0).
  • +
  • (Fix) Cursor position after inserting emoji, links & images.
  • +
  • (Fix) Teal customized accent was displayed as green.
  • +
  • (Fix) Some crashes from the crash report.
  • +
  • (Fix) Lots of bug fixes.
  • +
  • There are more stuff are not mentioned, find them out :stuck_out_tongue:
  • +
+

What left in FastHub? +

+
+

+ So far, FastHub has implemented almost all the features of GitHub, besides forProject Cards. Hopefully in the + next release FastHub will include that. The following releases will mainly be bug fixes or if a major feature is implemented. + +

+
+

How old is FastHub now? +

+
+

+ FastHub is now 5 months & a week old. Since v1.0.0 it has really grow with each and every release. The only way this was + possible, was due to the community helping out either via reporting bugs, feature requests or even give hand to fix things or + implement things in the app. I’m really grateful for having such a great community. Thank you guys! + +

+
+

Thank you to all who have contributed either via reporting bugs or via code contribution.

+ + + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d24120f8..6dacd3b7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -191,7 +191,7 @@ Wähle wie regelmäßig FastHub nach neuen Benachrichtigungen sucht. Benachrichtigunssynchronisierungsintervall Alle - Verhalten :\& Aussehen + Verhalten & Aussehen Listenanimationen aktivieren Listenanimationen Dialog deaktivieren, der verhindert, dass du FastHub ausversehen verlässt diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml new file mode 100644 index 00000000..f7aeec1e --- /dev/null +++ b/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,430 @@ + + 로딩 중, 잠시만 기다려주세요 + 행동 + 설정 + 제거 + 취소 + OK + 데이터 없음 + 검색 + FastHub를 계속 사용하려면 로그인하세요 + FastHub를 사용하기 위해서 GitHub에 로그인하세요 + 로그인 실패 + 로그인 + 공유 + 새로고침 + 프로필 + 오류 + 종료하려면 다시 한 번 눌러주세요 + 열림 + 닫힘 + 저장소 선택 + 팔로워 + 팔로잉 + 개요 + 팔로우 + 언팔로우 + 사용자 + 세부 + 파일을 다운로드하여 해당 컨텐츠를 보세요 + 최소 글자 (3) + 파일을 찾을 수 없습니다 + readme를 찾을 수 없습니다 + 다운로드 중… + 파일 다운로드 중… + 릴리즈됨 + 초안 저장됨 + 릴리즈 + 내용 없음 + 기여자 + 기여 + by + 영어로 요구 사항를 보내주세요 + 이슈 닫기 + 이슈 다시 열기 + 다시 열기 + 닫기 + 성공적으로 다시 열었습니다 + 대화 잠금은 다음을 의미합니다:\n·다른 사람은 이 이슈에 새로운 댓글을 남길 수 없습니다.\n·너와 이 저장소에 접근할 수 있는 공동작업자는 다른 사람이 볼 수 있는 댓글을 남길 수 있습니다\n·언제든 이 이슈를 잠금해제 할 수 있습니다.\n + 대화 잠금해제는 다음을 의미합니다:\n·모두가 이 이슈에 덧글을 남길 수 있습니다.\n·언제든 이 이슈를 잠글 수 있습니다.\n + 대화 잠금 + 대화 잠금해제 + 이슈 닫기 오류, 잠시 후 다시 시도해주세요 + 이슈 다시 열기 오류, 잠시 후 다시 시도해주세요 + 이슈 닫기 성공 +

설명 없음

+ 제목 1 + 제목 2 + 제목 3 + 굵게 + 기울임 + 취소선 + 순서없는 목록 + 순서있는 목록 + 제목 + 인용구 + 링크 + 이미지 + 제거 + 추가 + 변경 + 상태 + 계속 하시겠습니까? + 성공 + to + 댓글을 삭제하는 중에 오류가 발생했습니다 + 제거 + 댓글 + 댓글 + 병합 성공 + 파일 메뉴 + 파일 + 다운로드 + 뒤로가기 + 상위 폴더 + 코드 뷰어 + 브라우저로 열기 + 큰 파일 + 파일이 너무 커서 열 수 없습니다.\n"예"를 눌러 다운로드하세요 + 뷰어 + 보내기 + 이곳에 입력해주세요 + 설명 + 파일 이름 + 확장명을 가진 파일 이름 + 비공개 Gist + 공개 Gist + 다음으로 보내기 + 삭제 + Gist를 삭제하는 중에 오류가 발생했습니다. + 파일 없음 + 필수 입력란 + 제출 성공 + Gist 생성 + 클리어 + 사용자 + 제목 + 파일 + 이정표 + 자신을 담당자에 할당 + 이슈 보내기 + 이슈를 생성하는 중에 오류가 발생했습니다 + 이슈 생성 + 수정을 계속하려면 강조 표시를 선택 해제하세요 + 알림 + 읽지 않은 알림이 있습니다 + 열기 + 새로운 창 열기 + 알림 종류 + 꼬리표 + 꼬리표 없음 + 꼬리표 추가 성공 + 피드백 보내기 + 로그아웃 + 피드백 감사합니다 + 현재 버전 + 버전 + 개발을 지원하려면 광고를 활성화하세요 + 사용자 이름 + 비밀번호 + 이중 인증 코드 + 로그인 + Gist 설명 + 사용자 아바타를 클릭하여 사용자의 프로필을 열 수 있습니다 + 포크 이벤트를 길게 클릭하여 원본 또는 분기된 저장소를 엽니 다 + Release 다운로드 + 설정 + 파일 다운로드 또는 디렉토리 공유 + 댓글을 탭하면 작성자의 태그를 지정하거나 댓글을 수정할 수 있습니다.\n길게 누르면 삭제됩니다 + 저장소 즐겨찾기/즐겨찾기해제 + 구독 + 저장소 구독/구독해제 + 사이드바에서 더 빨리 액세스 할 수 있도록 저장소를 고정하세요 + 모두 닫기 + URL을 찾을 수 없습니다 + 마지막 업데이트 + 미리보기 + 구문 강조기 + 구문 강조기를 활성화/비활성화합니다. + \n더 많은 설정을 보려면 Markdown 편집기 아이콘을 스크롤하세요. + 생성일 + 파일 생성 날짜 + 파일 업데이트 날짜 + 모두 읽음으로 표시 + 모든 알림 + 읽지 않음 + 모든 + 저장소 삭제 + 저장소 삭제는 되돌릴 수 없습니다 + 30분 + 20분 + 10분 + 5분 + 1분 + 1시간 + 2시간 + 3시간 + 생성됨 + 커밋됨 + 다운로드됨 + 팔로우됨 + 이슈 댓글 + 구성원 + 풀 리퀘스트 댓글 + 푸시됨 + + 삭제됨 + 알 수 없음 + 커밋 댓글 + 분기 변경 + 담당자 + 수정 + \u2022 수정됨 + 이슈 업데이트 + 풀 리퀘스트 업데이트 + 이정표 없음 + 추가 + 완료 + + 이정표 생성 + 이정표를 생성하는 중에 오류가 발생했습니다 + 만기일 + 담당자 없음 + 이 분기 + 커밋이 선택한 분기로 전환되었습니다 + 일반 + FastHub이 새 알림을 확인하는 빈도를 변경합니다 + 동기화 간격 + 항상 + 행동 + 사용자 정의 + 목록 효과 활성화 + 목록 효과 + 앱 종료 확인 다이얼로그를 비활성화합니다 + 앱 종료 확인 비활성화 + 복원 + 백업 + 백업 성공! + 복원할 백업 선택 + 허락되지 않은 권한 + 마지막 업데이트: %s + 지금 + 저장되지 않은 변경사항을 삭제하시겠습니까? + 비공개 + 원형 아바타 대신 둥근 사각형 아바타를 사용합니다 + 둥근 사각형 아바타 + 앱 평점 매기기 + 개발자 + GitHub에서 포크하기 + 이메일 보내기 + FastHub에 관한 질문 + 피드백 + 오류 보고 + 오류가 있습니까? 이곳에 입력해주세요. + 앱 정보 + 알림 + 끄기 + 인증되지 않은 사용자 + 이중 인증이 필요합니다 + 이슈 없음 + URL 복사 + 복사됨 + 커밋 메시지 + 서버와 통신하는 중 오류가 발생했습니다 + API를 요청하는 중에 오류가 발생했습니다 + 서버 요청 오류, 잠시 후 다시 시도하세요 + 알림을 읽음으로 표시 + Gist 포크 + 기본 브라우저로 로그인 (OAuth) + 또는 + 알림 읽음 비활성화 + 알림을 클릭하면 읽음으로 표시 기능 사용을 비활성화합니다 + 테마 + 기본 테마 선택 + 테마 강조 색상 선택 + 테마 강조 색상 + 웹사이트 + 개발 지원 + 대단히 감사합니다! + 테마가 제대로 적용되지 않으면, 앱을 수동으로 재시작해주세요 + 고정 + 고정됨 + 고정해제 + 아직 고정된 저장소가 없으므로 여기에서 볼 수 있도록 고정하세요.\nP.S: 많이 액세스할수록 저장소는 위에 배치될 것입니다. + + 아니요 + Feeds 없음 + Gists 없음 + 덧글 없음 + 알림 없음 + Follower 없음 + Following 없음 + 저장소 없음 + 즐겨찾기된 저장소 없음 + 커밋 없음 + 기여자 없음 + 릴리즈 없음 + 닫힌 이슈 없음 + 열린 이슈 없음 + 이벤트 없음 + 열린 풀 리퀘스트 없음 + 닫힌 풀 리퀘스트 없음 + 검색 결과 없음 + 파일을 보기 위해 FastHub에서 파일을 저장하려면 권한을 허용하세요 + 공개 Gist + 광고 활성화 + 이슈 없음 + 읽지 않은 알림 없음 + 내 Gist + 변경사항 + 클릭하여 알림 목록을 열거나 옆으로 밀어 닫으세요 + 길게 누르면 어디서나 기본 화면으로 이동합니다 + 생성됨 + 담당됨 + 언급됨 + 이름 + 색상 + 꼬리표 샹성 + 조직 + 조직 + 구성원 + + Members + 구성원 없음 + 팀 없음 + 조직 없음 + 너의 조직을 찾을 수 없습니까? + 읽음으로 표시 + 효과 + 팝업 효과를 활성화합니다 + 팝업 효과 + 이정표 + 담당자 + 몇몇 검사를 통과하지 못했습니다 + 몇몇 검사가 지연되었습니다 + 모든 검사를 통과했습니다 + 정렬 + 최신 순 + 오래된 순 + 많은 댓글 순 + 최신 댓글 순 + 최신 업데이트 순 + 최소 최신 업데이트 순 + 최신 버전입니다 + 새로운 버전이 있습니다 + 검색 내용을 입력해주세요 + 길게 누르면 이슈 티켓을 즉시 생성 할 수 있습니다 + 이 풀 리퀘스트는 합병될 수 있습니다 + 검토됨 + 검토 취소됨 + 변경 승인됨 + 반응 없음 + 반응 + 줄 바꿈 + 기본적으로 코드 뷰어에서 코드 줄 바꿈 + 코드 줄 바꿈 + 오픈소스 라이브러리 + 알림음을 활성화합니다 + 알림음 활성화 + 알림 활성화 + 개인 토큰으로 로그인 + 개인 토큰 + 기본 인증으로 로그인 + 실제로 조직에 속해 있고 여기에서 볼 수 없다면 다음 링크를 따르십시오. + \nhttps://help.github.com/articles/about-third-party-application-restrictions\nPS: 조직에 FastHub 엑세스 권한을 부여하고 액세스 토큰을 사용해서 로그인할 수 있습니다.\n또한 https://github.com/settings/applications에서 FastHub를 찾아 조직 액세스로 스크롤 한 다음 승인 버튼을 클릭하세요. + 삽입 + 선택 + 사진 선택 + $2.00 지원 + $5.00 지원 + $10.00 지원 + $20.00 지원 + 언어 + 언어 + 언어 선택 + 언어를 선택하세요. + 구독 취소 + from + in + 구독 + 이 저장소에서 이슈가 사용 중지되었습니다 + 엑세스 토큰 + 기본 인증 + 로그인 종류 선택 + In Files + In Paths + 요청 검토 + Slack 참여 + FastHub Slack에 참여하시겠습니까? + 초대 성공 + 답글 + 이미지 불러오기에 실패했습니다 + 병합 희망 + 구독자 + 검토자 + 검토자 없음 + %2$s%3$s을 사용하여 %1$s에서 보냈습니다 + 서명 활성화 + 서명을 통해 전송을 활성화합니다 + 서명 상자 활성화 + 확인란을 사용하여 텍스트 편집기에서 서명을 활성화/비활성화 할 수 있습니다. + 태그 + 댓글 달기 + 배너 소개 + With FastHub 2.5.0, you can now better express yourself with banners + for your profile page.\n\nAnyone using the FastHub app, will see your header, and you\'ll + begin seeing other peoples headers as well! If you\'d like to create a banner for yourself, + make it 1280x384 or divisible, otherwise, it may get cropped.\n\nYou can add or change your + banner at any time, by creating a gist described "header.fst" with a file containing a + direct link to the header image.\n\nOr even simpler, just use the built-in image chooser! + + 배너 선택 + 이미지 불러오기 오류, 다시 시도하세요 + 급상승 + GitHub 제한 때문에 emojies로 정렬이 실제로 작동하지 않습니다 + 위로 스크롤 + 아래로 스크롤 + 참여됨 + 모두 선택됨 + 모두 선택 취소됨 + 구분선 + 사용자 없음 + 급상승 없음 + 초기화 + 적용 + 필터 + 종류 + 정렬 순서 + 담당자 추가 성공 + 검토자 추가 성공 + 이정표 추가 성공 + Feed + 프리미엄 테마 + 코드 색상 표 + GitHub API의 모든 항목에 액세스하려면 GitHub 계정에 로그인하십시오. + 그렇지 않으면 GitHub API로 전송 된 토큰은 엔터프라이즈 계정에서 전송되기 때문에 Enterprise GitHub가 아닌 + 다른 항목에 액세스 할 때마다 강제 로그아웃 될 수 있습니다. + 계정 추가 + 계정 선택 + 일치하지 않음 + 경고 + 소유자 + 원본 포스터 + 검토 취소 + 테마에서 색상 네비게이션 바를 비활성화합니다 + 무색 네비게이션 + 알림 소리를 선택합니다 + 알림 소리 선택 + GIF 자동 재생 사용을 비활성화합니다 + GIF 자동 재생 사용 비활성화 + 요청된 변경 사항 + Google Play 서비스를 사용할 수 없음 + Gist 수정 + 내용 + 확장 + SHA 복사 + 코드로 보기 + 앱 애니메이션 + 모든 앱 애니메이션을 비활성화합니다 + 이 풀 리퀘스트는 현재 병합될 수 없습니다 +
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9624342f..dd08c90e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -333,7 +333,7 @@ Token Pessoal Entra com autenticação básica Se você está ligado a alguma organização e não consegue vê-las aqui, por favor entre no link abaixo. - \nhttps://help.github.com/articles/about-third-party-application-restrictions\nPS: Você poderia usar o token de acesso para entrar, o que vai + \nhttps://help.github.com/articles/about-third-party-application-restrictions\nPS: Você poderia usar o token de acesso para entrar, o que vai permitir acesso ao FastHub para poder ver a lista de suas organizações. Inserir Selecionar @@ -415,5 +415,18 @@ Original Poster Cancelar Reviews Desabilitar a coloração da barra de navegação nos temas -Desabilitar a coloração da barra de navegação + Desabilitar a coloração da barra de navegação + Escolher som de notificação personalizado + Escolher Som de Notificação + Desabilitar o auto play de GIFs + Dessabilitar Reproduzir GIF + mudanças solicitadas + Google Play Service insdisponível + Editar Gist + Conteúdo + expandir + Copiar SHA + Ver como código + Animações no App + Desabilitar todas as animações no App. diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index f67089a7..faeb0a9a 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -130,6 +130,7 @@ Český Español Български + 한국어 @@ -148,6 +149,7 @@ cs es bg + ko diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3425700..66812982 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,6 +91,7 @@ Unlock Unlock Everything Feeds - Loading, please wait… @@ -556,4 +556,13 @@ Edit Gist Content expand + Copy SHA + View as code + In App Animations + Disable in App animations everywhere. + This PR can\'t be merged now. + Projects + No Projects + No Cards + Added by %s diff --git a/app/src/main/res/values/theme_amlod.xml b/app/src/main/res/values/theme_amlod.xml index 6d2e6912..90f2ab1d 100644 --- a/app/src/main/res/values/theme_amlod.xml +++ b/app/src/main/res/values/theme_amlod.xml @@ -18,7 +18,7 @@ @color/amlodWindowBackground #040408 #010102 - #483078 + #2962FF ?colorAccent ?colorPrimary true @@ -150,7 +150,7 @@ @style/TimeLineBackgroundAmlod #040408 #08080F - #483078 + #2962FF @color/amlodWindowBackground @@ -218,7 +218,7 @@ @style/Theme.Mal.Dark.PopupOverlay #040408 #08080F - #483078 + #2962FF false #eee #ffe0e0e0 diff --git a/app/src/main/res/values/theme_bluish.xml b/app/src/main/res/values/theme_bluish.xml index 77dcb5d0..4d37c0a3 100644 --- a/app/src/main/res/values/theme_bluish.xml +++ b/app/src/main/res/values/theme_bluish.xml @@ -154,10 +154,14 @@ @style/CommentBoxDarkBluish #eee @style/TimeLineBackgroundBluish + @color/dark_patch_addition_color + @color/dark_patch_deletion_color + @color/dark_patch_ref_color + @color/bluishDivider + @color/bluishWindowBackground @color/bluish_primary @color/bluish_primary_dark @color/bluish_accent - @color/bluishWindowBackground + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/theme_midnight_blue.xml b/app/src/main/res/values/theme_midnight_blue.xml deleted file mode 100644 index 1dddbdd0..00000000 --- a/app/src/main/res/values/theme_midnight_blue.xml +++ /dev/null @@ -1,204 +0,0 @@ - - - - #FAFAFA - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/behaviour_settings.xml b/app/src/main/res/xml/behaviour_settings.xml index 4195416f..bbdbbe8e 100644 --- a/app/src/main/res/xml/behaviour_settings.xml +++ b/app/src/main/res/xml/behaviour_settings.xml @@ -4,7 +4,7 @@ diff --git a/app/src/main/res/xml/customization_settings.xml b/app/src/main/res/xml/customization_settings.xml index 5efd0847..48c34194 100644 --- a/app/src/main/res/xml/customization_settings.xml +++ b/app/src/main/res/xml/customization_settings.xml @@ -10,29 +10,37 @@ + + + diff --git a/app/src/release/java/com/fastaccess/provider/fabric/FabricProvider.java b/app/src/release/java/com/fastaccess/provider/fabric/FabricProvider.java deleted file mode 100644 index b77fdf12..00000000 --- a/app/src/release/java/com/fastaccess/provider/fabric/FabricProvider.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.fastaccess.provider.fabric; - -import android.content.Context; -import android.support.annotation.NonNull; - -import com.fastaccess.BuildConfig; - -/** - * Created by kosh on 14/08/2017. - */ - -public class FabricProvider { - - public static void initFabric(@NonNull Context context) { - Fabric fabric = new Fabric.Builder(context) - .kits(new Crashlytics.Builder() - .core(new CrashlyticsCore.Builder().disabled(BuildConfig.DEBUG).build()) - .build()) - .debuggable(BuildConfig.DEBUG) - .build(); - Fabric.with(fabric); - } - - public static void logPurchase(@NonNull String productKey) { - Answers.getInstance().logPurchase(PurchaseEvent().putItemName(productKey).putSuccess(true)); - } -} diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 238f4a76..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,63 +0,0 @@ -image: Visual Studio 2017 -clone_folder: 'C:\FastHub' - -# skip branch build if there is an active pull request -skip_branch_with_pr: true -skip_commits: - files: - - '**/*.md' - message: \[(skip app veyor|app veyor skip|skip appveyor|appveyor skip)\] - -environment: - ANDROID_HOME: 'A:\' - GRADLE_USER_HOME: 'G:\' - -init: - - ps: | - subst F: C:\FastHub - mkdir C:\gradle.home - subst G: C:\gradle.home - mkdir C:\Android\android-sdk - subst A: C:\Android\android-sdk - appveyor DownloadFile "https://dl.google.com/android/repository/sdk-tools-windows-3859397.zip" -FileName "C:\android-tools.zip" - Write-Host "Extracting SDK tools..." - 7z x "C:\android-tools.zip" -o"$env:ANDROID_HOME" | Out-Null - -install: - - ps: | - Write-Output "" > ~\.android\repositories.cfg - if ($env:APPVEYOR_PULL_REQUEST_NUMBER){ - if (($env:APPVEYOR_REPO_COMMIT_AUTHOR -ne 'Yakov') -and ($env:APPVEYOR_REPO_COMMIT_AUTHOR -ne 'Kosh Sergani')) { - Write-Host "PR detected. Installing C# Script Engine and doing translations check:" - cinst cs-script --version 3.26.2.0 - cscs - cscs -ac:2 -nl $env:APPVEYOR_BUILD_FOLDER\.github\check_translations.cs - } - } - Write-Host "Installing Android packages:" - $pkgs = '"platform-tools"', '"extras;android;m2repository"', '"extras;google;m2repository"', '"build-tools;26.0.1"', '"platforms;android-26"' - foreach ($pkg in $pkgs) { - Write-Host "Installing ${pkg}:" - echo "y" | & $env:ANDROID_HOME\tools\bin\sdkmanager.bat ${pkg} - } -build_script: - - cmd: | - CD /D F: - F:\gradlew clean assembleDebug --stacktrace - -after_build: - - ps: Rename-Item -Path "$env:APPVEYOR_BUILD_FOLDER\app\build\outputs\apk\debug\app-debug.apk" -NewName "fasthub-debug-$env:APPVEYOR_BUILD_VERSION.apk" - -test: off - -artifacts: -- path: \app\build\outputs\apk\debug\fasthub-debug-%APPVEYOR_BUILD_VERSION%.apk - -deploy: off - -notifications: -- provider: GitHubPullRequest - template: ':x: [Build {{&projectName}} {{buildVersion}} {{status}}]({{buildUrl}}) (commit {{commitUrl}} by @{{&commitAuthorUsername}})

**Message(s):**
{{#jobs}}{{#messages}}
{{message}}
{{/messages}}{{/jobs}}' - on_build_success: false - on_build_failure: true - on_build_status_changed: false \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3147699c..0b0e929f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext { - taskRequests = getGradle().getStartParameter().getTaskRequests().toString() - isProduction = taskRequests.toLowerCase().contains("release") butterKnifeVersion = '8.5.1' state_version = '1.1.0' lombokVersion = '1.12.6' @@ -15,7 +13,7 @@ buildscript { assertjVersion = '2.5.0' espresseVersion = '2.2.2' requery = '1.3.2' - kotlin_version = '1.1.4' + kotlin_version = '1.1.4-2' commonmark = '0.9.0' } repositories { @@ -24,11 +22,11 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:3.0.0-beta2' + classpath 'com.android.tools.build:gradle:3.0.0-beta5' classpath 'com.google.gms:google-services:3.0.0' classpath 'com.novoda:gradle-build-properties-plugin:0.3' classpath 'com.dicedmelon.gradle:jacoco-android:0.1.2' - if (isProduction) classpath 'io.fabric.tools:gradle:1.22.2' + classpath 'io.fabric.tools:gradle:1.24.1' classpath 'com.apollographql.apollo:gradle-plugin:0.4.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "com.github.viswaramamoorthy:gradle-util-plugins:0.1.0-RELEASE" diff --git a/debug_gradle.properties b/debug_gradle.properties index d5332fd0..1303a8bd 100644 --- a/debug_gradle.properties +++ b/debug_gradle.properties @@ -1,5 +1,5 @@ # Below API Keys are meant for debugging purpose & they aren't being used in production. -org.gradle.jvmargs=-Xmx3g -XX:MaxPermSize=2048m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx2536M android_store_password=kosh2010 android_key_password=kosh2010 android_key_alias=FastAccess @@ -8,5 +8,4 @@ github_secret=b2d158f949d3615078eaf570ff99eba81cfa1ff9 imgur_client_id=5fced7f255e1dc9 imgur_secret=03025033403196a4b68b48f0738e67ef136ad64f redirect_url=fasthub://login -android.enableD8=true android.sdk.channel=2 diff --git a/jobdispatcher/build.gradle b/jobdispatcher/build.gradle new file mode 100644 index 00000000..d326ddf2 --- /dev/null +++ b/jobdispatcher/build.gradle @@ -0,0 +1,79 @@ +apply plugin: "com.android.library" + +android { + compileSdkVersion 26 + buildToolsVersion "26.0.1" + + defaultConfig { + minSdkVersion 16 + targetSdkVersion 26 + versionCode 1 + versionName "0.8.0" + testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner' + } + + defaultPublishConfig "release" + publishNonDefault true + + buildTypes { + debug { + testCoverageEnabled true + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + } + } + + sourceSets { + // A set of testing helpers that are shared across test types + testLib { java.srcDir("src/main") } + test { java.srcDir("src/testLib") } // Robolectric tests + androidTest { java.srcDir("src/testLib") } // Android (e2e) tests + } +} + +dependencies { + // The main library only depends on the Android support lib + compile "com.android.support:support-v4:26.0.1" + + def junit = 'junit:junit:4.12' + def robolectric = 'org.robolectric:robolectric:3.3.2' + + // The common test library uses JUnit + testLibCompile junit + + // The unit tests are written using JUnit, Robolectric, and Mockito + testCompile junit + testCompile robolectric + testCompile 'org.mockito:mockito-core:2.2.5' + + // The Android (e2e) tests are written using JUnit and the test support lib + androidTestCompile junit + androidTestCompile 'com.android.support.test:runner:0.5' +} + +task javadocs(type: Javadoc) { + description "Generate Javadocs" + source = android.sourceSets.main.java.sourceFiles + classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) + classpath += configurations.compile + failOnError false +} + +task javadocsJar(type: Jar, dependsOn: javadocs) { + description "Package Javadocs into a jar" + classifier = "javadoc" + from javadocs.destinationDir +} + +task sourcesJar(type: Jar) { + description "Package sources into a jar" + classifier = "sources" + from android.sourceSets.main.java.sourceFiles +} + +task aar(dependsOn: "assembleRelease") { + group "artifact" + description "Builds the library AARs" +} diff --git a/jobdispatcher/coverage.gradle b/jobdispatcher/coverage.gradle new file mode 100644 index 00000000..204f2dc2 --- /dev/null +++ b/jobdispatcher/coverage.gradle @@ -0,0 +1,40 @@ +apply plugin: "jacoco" + +jacoco { + // see https://github.com/jacoco/jacoco/pull/288 and the top build.gradle + toolVersion "0.7.6.201602180812" +} + +android { + testOptions { + unitTests.all { + systemProperty "robolectric.logging.enabled", true + systemProperty "robolectric.logging", "stdout" + + jacoco { + includeNoLocationClasses = true + } + } + } +} + +// ignore these when generating coverage +def ignoredPrefixes = ['R$', 'R.class', 'BuildConfig.class'] + +task coverage(type: JacocoReport, dependsOn: ["testDebugUnitTest"]) { + group = "Reports" + description = "Generate a coverage report" + + classDirectories = fileTree( + dir: "${project.buildDir}/intermediates/classes/debug/com/firebase/", + exclude: { d -> ignoredPrefixes.any { p -> d.file.name.startsWith(p) } } + ) + sourceDirectories = files(["src/main/java/com/firebase/"]) + executionData = files("${project.buildDir}/jacoco/testDebugUnitTest.exec") + + reports { + xml.enabled = true + html.enabled = true + html.destination "${buildDir}/coverage_html" + } +} diff --git a/jobdispatcher/src/androidTest/AndroidManifest.xml b/jobdispatcher/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..c87f27fc --- /dev/null +++ b/jobdispatcher/src/androidTest/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + diff --git a/jobdispatcher/src/androidTest/java/com/firebase/jobdispatcher/EndToEndTest.java b/jobdispatcher/src/androidTest/java/com/firebase/jobdispatcher/EndToEndTest.java new file mode 100644 index 00000000..48b1048b --- /dev/null +++ b/jobdispatcher/src/androidTest/java/com/firebase/jobdispatcher/EndToEndTest.java @@ -0,0 +1,69 @@ +// Copyright 2017 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Basic end to end test for the JobDispatcher. Requires Google Play services be installed and + * available. + */ +@RunWith(AndroidJUnit4.class) +public final class EndToEndTest { + private Context appContext; + private FirebaseJobDispatcher dispatcher; + + @Before public void setUp() { + appContext = InstrumentationRegistry.getTargetContext(); + dispatcher = new FirebaseJobDispatcher(new GooglePlayDriver(appContext)); + TestJobService.reset(); + } + + @Test public void basicImmediateJob() throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + TestJobService.setProxy(new TestJobService.JobServiceProxy() { + @Override + public boolean onStartJob(JobParameters params) { + latch.countDown(); + return false; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } + }); + + dispatcher.mustSchedule( + dispatcher.newJobBuilder() + .setService(TestJobService.class) + .setTrigger(Trigger.NOW) + .setTag("basic-immediate-job") + .build()); + + assertTrue("Latch wasn't counted down as expected", latch.await(120, TimeUnit.SECONDS)); + } +} diff --git a/jobdispatcher/src/main/AndroidManifest.xml b/jobdispatcher/src/main/AndroidManifest.xml new file mode 100644 index 00000000..71205bd8 --- /dev/null +++ b/jobdispatcher/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/BundleProtocol.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/BundleProtocol.java new file mode 100644 index 00000000..774a8340 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/BundleProtocol.java @@ -0,0 +1,49 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +final class BundleProtocol { + static final String PACKED_PARAM_BUNDLE_PREFIX = "com.firebase.jobdispatcher."; + + // PACKED_PARAM values are only read on the client side, so as long as the + // extraction process gets the same changes then it's fine. + static final String PACKED_PARAM_CONSTRAINTS = "constraints"; + static final String PACKED_PARAM_LIFETIME = "persistent"; + static final String PACKED_PARAM_RECURRING = "recurring"; + static final String PACKED_PARAM_SERVICE = "service"; + static final String PACKED_PARAM_TAG = "tag"; + static final String PACKED_PARAM_EXTRAS = "extras"; + static final String PACKED_PARAM_TRIGGER_TYPE = "trigger_type"; + static final String PACKED_PARAM_TRIGGER_WINDOW_END = "window_end"; + static final String PACKED_PARAM_TRIGGER_WINDOW_START = "window_start"; + static final int TRIGGER_TYPE_EXECUTION_WINDOW = 1; + static final int TRIGGER_TYPE_IMMEDIATE = 2; + static final int TRIGGER_TYPE_CONTENT_URI = 3; + static final String PACKED_PARAM_RETRY_STRATEGY_INITIAL_BACKOFF_SECONDS = + "initial_backoff_seconds"; + static final String PACKED_PARAM_RETRY_STRATEGY_MAXIMUM_BACKOFF_SECONDS = + "maximum_backoff_seconds"; + static final String PACKED_PARAM_RETRY_STRATEGY_POLICY = "retry_policy"; + static final String PACKED_PARAM_REPLACE_CURRENT = "replace_current"; + static final String PACKED_PARAM_CONTENT_URI_FLAGS_ARRAY = "content_uri_flags_array"; + static final String PACKED_PARAM_CONTENT_URI_ARRAY = "content_uri_array"; + static final String PACKED_PARAM_TRIGGERED_URIS = "triggered_uris"; + static final String PACKED_PARAM_OBSERVED_URI = "observed_uris"; + + BundleProtocol() { + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Constraint.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Constraint.java new file mode 100644 index 00000000..ff413fb2 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Constraint.java @@ -0,0 +1,108 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.IntDef; +import android.support.annotation.VisibleForTesting; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A Constraint is a runtime requirement for a job. A job only becomes eligible to run once its + * trigger has been activated and all constraints are satisfied. + */ +public final class Constraint { + /** + * Only run the job when an unmetered network is available. + */ + public static final int ON_UNMETERED_NETWORK = 1; + + /** + * Only run the job when a network connection is available. If both this and + * {@link #ON_UNMETERED_NETWORK} is provided, {@link #ON_UNMETERED_NETWORK} will take + * precedence. + */ + public static final int ON_ANY_NETWORK = 1 << 1; + + /** + * Only run the job when the device is currently charging. + */ + public static final int DEVICE_CHARGING = 1 << 2; + + /** + * Only run the job when the device is idle. This is ignored for devices that don't expose the + * concept of an idle state. + */ + public static final int DEVICE_IDLE = 1 << 3; + + @VisibleForTesting + static final int[] ALL_CONSTRAINTS = { + ON_ANY_NETWORK, ON_UNMETERED_NETWORK, DEVICE_CHARGING, DEVICE_IDLE}; + + /** Constraint shouldn't ever be instantiated. */ + private Constraint() {} + + /** + * A tooling type-hint for any of the valid constraint values. + */ + @IntDef(flag = true, value = { + ON_ANY_NETWORK, + ON_UNMETERED_NETWORK, + DEVICE_CHARGING, + DEVICE_IDLE, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface JobConstraint {} + + /** + * Compact a provided array of constraints into a single int. + * + * @see #uncompact(int) + */ + static int compact(@JobConstraint int[] constraints) { + int result = 0; + if (constraints == null) { + return result; + } + for (int c : constraints) { + result |= c; + } + return result; + } + + /** + * Unpack a single int into an array of constraints. + * + * @see #compact(int[]) + */ + static int[] uncompact(int compactConstraints) { + int length = 0; + for (int c : ALL_CONSTRAINTS) { + length += (compactConstraints & c) == c ? 1 : 0; + } + int[] list = new int[length]; + + int i = 0; + for (int c : ALL_CONSTRAINTS) { + if ((compactConstraints & c) == c) { + list[i++] = c; + } + } + + return list; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/DefaultJobValidator.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/DefaultJobValidator.java new file mode 100644 index 00000000..4804804e --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/DefaultJobValidator.java @@ -0,0 +1,288 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.RetryStrategy.RETRY_POLICY_EXPONENTIAL; +import static com.firebase.jobdispatcher.RetryStrategy.RETRY_POLICY_LINEAR; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.os.Parcel; +import android.support.annotation.CallSuper; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * Validates Jobs according to some safe standards. + *

+ * Custom JobValidators should typically extend from this. + */ +public class DefaultJobValidator implements JobValidator { + + /** + * The maximum length of a tag, in characters (i.e. String.length()). Strings longer than this + * will cause validation to fail. + */ + public static final int MAX_TAG_LENGTH = 100; + + /** + * The maximum size, in bytes, that the provided extras bundle can be. Corresponds to + * {@link Parcel#dataSize()}. + */ + public final static int MAX_EXTRAS_SIZE_BYTES = 10 * 1024; + + /** Private ref to the Context. Necessary to check that the manifest is configured correctly. */ + private final Context context; + + public DefaultJobValidator(Context context) { + this.context = context; + } + + /** @see {@link #MAX_EXTRAS_SIZE_BYTES}. */ + private static int measureBundleSize(Bundle extras) { + Parcel p = Parcel.obtain(); + extras.writeToParcel(p, 0); + int sizeInBytes = p.dataSize(); + p.recycle(); + + return sizeInBytes; + } + + /** Combines two {@literal Lists} together. */ + @Nullable + private static List mergeErrorLists(@Nullable List errors, + @Nullable List newErrors) { + if (errors == null) { + return newErrors; + } + if (newErrors == null) { + return errors; + } + + errors.addAll(newErrors); + return errors; + } + + @Nullable + private static List addError(@Nullable List errors, String newError) { + if (newError == null) { + return errors; + } + if (errors == null) { + return getMutableSingletonList(newError); + } + + Collections.addAll(errors, newError); + + return errors; + } + + @Nullable + private static List addErrorsIf(boolean condition, List errors, String newErr) { + if (condition) { + return addError(errors, newErr); + } + + return errors; + } + + /** + * Attempts to validate the provided {@code JobParameters}. If the JobParameters is valid, null will be + * returned. If the JobParameters has errors, a list of those errors will be returned. + */ + @Nullable + @Override + @CallSuper + public List validate(JobParameters job) { + List errors = null; + + errors = mergeErrorLists(errors, validate(job.getTrigger())); + errors = mergeErrorLists(errors, validate(job.getRetryStrategy())); + + if (job.isRecurring() && job.getTrigger() == Trigger.NOW) { + errors = addError(errors, "ImmediateTriggers can't be used with recurring jobs"); + } + + errors = mergeErrorLists(errors, validateForTransport(job.getExtras())); + if (job.getLifetime() > Lifetime.UNTIL_NEXT_BOOT) { + //noinspection ConstantConditions + errors = mergeErrorLists(errors, validateForPersistence(job.getExtras())); + } + + errors = mergeErrorLists(errors, validateTag(job.getTag())); + errors = mergeErrorLists(errors, validateService(job.getService())); + + return errors; + } + + /** + * Attempts to validate the provided Trigger. If valid, null is returned. Otherwise a list of + * errors will be returned. + *

+ * Note that a Trigger that passes validation here is not necessarily valid in all permutations + * of a JobParameters. For example, an Immediate is never valid for a recurring job. + * @param trigger + */ + @Nullable + @Override + @CallSuper + public List validate(JobTrigger trigger) { + if (trigger != Trigger.NOW + && !(trigger instanceof JobTrigger.ExecutionWindowTrigger) + && !(trigger instanceof JobTrigger.ContentUriTrigger)) { + return getMutableSingletonList("Unknown trigger provided"); + } + + return null; + } + + /** + * Attempts to validate the provided RetryStrategy. If valid, null is returned. Otherwise a list + * of errors will be returned. + */ + @Nullable + @Override + @CallSuper + public List validate(RetryStrategy retryStrategy) { + List errors = null; + + int policy = retryStrategy.getPolicy(); + int initial = retryStrategy.getInitialBackoff(); + int maximum = retryStrategy.getMaximumBackoff(); + + errors = addErrorsIf(policy != RETRY_POLICY_EXPONENTIAL && policy != RETRY_POLICY_LINEAR, + errors, "Unknown retry policy provided"); + errors = addErrorsIf(maximum < initial, + errors, "Maximum backoff must be greater than or equal to initial backoff"); + errors = addErrorsIf(300 > maximum, + errors, "Maximum backoff must be greater than 300s (5 minutes)"); + errors = addErrorsIf(initial < 30, + errors, "Initial backoff must be at least 30s"); + + return errors; + } + + @Nullable + private List validateForPersistence(Bundle extras) { + List errors = null; + + if (extras != null) { + // check the types to make sure they're persistable + for (String k : extras.keySet()) { + errors = addError(errors, validateExtrasType(extras, k)); + } + } + + return errors; + } + + @Nullable + private List validateForTransport(Bundle extras) { + if (extras == null) { + return null; + } + + int bundleSizeInBytes = measureBundleSize(extras); + if (bundleSizeInBytes > MAX_EXTRAS_SIZE_BYTES) { + return getMutableSingletonList(String.format(Locale.US, + "Extras too large: %d bytes is > the max (%d bytes)", + bundleSizeInBytes, MAX_EXTRAS_SIZE_BYTES)); + } + + return null; + } + + @Nullable + private String validateExtrasType(Bundle extras, String key) { + Object o = extras.get(key); + + if (o == null + || o instanceof Integer + || o instanceof Long + || o instanceof Double + || o instanceof String + || o instanceof Boolean) { + return null; + } + + return String.format(Locale.US, + "Received value of type '%s' for key '%s', but only the" + + " following extra parameter types are supported:" + + " Integer, Long, Double, String, and Boolean", + o == null ? null : o.getClass(), key); + } + + private List validateService(String service) { + if (service == null || service.isEmpty()) { + return getMutableSingletonList("Service can't be empty"); + } + + if (context == null) { + return getMutableSingletonList("Context is null, can't query PackageManager"); + } + + PackageManager pm = context.getPackageManager(); + if (pm == null) { + return getMutableSingletonList("PackageManager is null, can't validate service"); + } + + final String msg = "Couldn't find a registered service with the name " + service + + ". Is it declared in the manifest with the right intent-filter?"; + + Intent executeIntent = new Intent(JobService.ACTION_EXECUTE); + executeIntent.setClassName(context, service); + List intentServices = pm.queryIntentServices(executeIntent, 0); + if (intentServices == null || intentServices.isEmpty()) { + return getMutableSingletonList(msg); + } + + for (ResolveInfo info : intentServices) { + if (info.serviceInfo != null && info.serviceInfo.enabled) { + // found a match! + return null; + } + } + + return getMutableSingletonList(msg); + } + + private List validateTag(String tag) { + if (tag == null) { + return getMutableSingletonList("Tag can't be null"); + } + + if (tag.length() > MAX_TAG_LENGTH) { + return getMutableSingletonList("Tag must be shorter than " + MAX_TAG_LENGTH); + } + + return null; + } + + @NonNull + private static List getMutableSingletonList(String msg) { + ArrayList strings = new ArrayList<>(); + strings.add(msg); + return strings; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Driver.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Driver.java new file mode 100644 index 00000000..fe832721 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Driver.java @@ -0,0 +1,62 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.NonNull; +import com.firebase.jobdispatcher.FirebaseJobDispatcher.CancelResult; +import com.firebase.jobdispatcher.FirebaseJobDispatcher.ScheduleResult; + +/** + * Driver represents a component that understands how to schedule, validate, and execute jobs. + */ +public interface Driver { + + /** + * Schedules the provided Job. + * + * @return one of the SCHEDULE_RESULT_ constants + */ + @ScheduleResult + int schedule(@NonNull Job job); + + /** + * Cancels the job with the provided tag and class. + * + * @return one of the CANCEL_RESULT_ constants. + */ + @CancelResult + int cancel(@NonNull String tag); + + /** + * Cancels all jobs registered with this Driver. + * + * @return one of the CANCEL_RESULT_ constants. + */ + @CancelResult + int cancelAll(); + + /** + * Returns a JobValidator configured for this backend. + */ + @NonNull + JobValidator getValidator(); + + /** + * Indicates whether the backend is available. + */ + boolean isAvailable(); +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ExecutionDelegator.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ExecutionDelegator.java new file mode 100644 index 00000000..1dbacb3c --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ExecutionDelegator.java @@ -0,0 +1,160 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static android.content.Context.BIND_AUTO_CREATE; + +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.support.v4.util.SimpleArrayMap; +import android.util.Log; +import com.firebase.jobdispatcher.JobService.JobResult; +import java.lang.ref.WeakReference; + +/** + * ExecutionDelegator tracks local Binder connections to client JobServices and handles + * communication with those services. + */ +/* package */ class ExecutionDelegator { + @VisibleForTesting + static final int JOB_FINISHED = 1; + + static final String TAG = "FJD.ExternalReceiver"; + + interface JobFinishedCallback { + void onJobFinished(@NonNull JobInvocation jobInvocation, @JobResult int result); + } + + /** + * A mapping of {@link JobInvocation} to (local) binder connections. + * Synchronized by itself. + */ + private final SimpleArrayMap serviceConnections = + new SimpleArrayMap<>(); + private final ResponseHandler responseHandler = + new ResponseHandler(Looper.getMainLooper(), new WeakReference<>(this)); + private final Context context; + private final JobFinishedCallback jobFinishedCallback; + + ExecutionDelegator(Context context, JobFinishedCallback jobFinishedCallback) { + this.context = context; + this.jobFinishedCallback = jobFinishedCallback; + } + + /** + * Executes the provided {@code jobInvocation} by kicking off the creation of a new Binder + * connection to the Service. + * + * @return true if the service was bound successfully. + */ + boolean executeJob(JobInvocation jobInvocation) { + if (jobInvocation == null) { + return false; + } + + JobServiceConnection conn = new JobServiceConnection(jobInvocation, + responseHandler.obtainMessage(JOB_FINISHED)); + + synchronized (serviceConnections) { + JobServiceConnection oldConnection = serviceConnections.put(jobInvocation, conn); + if (oldConnection != null) { + Log.e(TAG, "Received execution request for already running job"); + } + return context.bindService(createBindIntent(jobInvocation), conn, BIND_AUTO_CREATE); + } + } + + @NonNull + private Intent createBindIntent(JobParameters jobParameters) { + Intent execReq = new Intent(JobService.ACTION_EXECUTE); + execReq.setClassName(context, jobParameters.getService()); + return execReq; + } + + void stopJob(JobInvocation job) { + synchronized (serviceConnections) { + JobServiceConnection jobServiceConnection = serviceConnections.remove(job); + if (jobServiceConnection != null) { + jobServiceConnection.onStop(); + safeUnbindService(jobServiceConnection); + } + } + } + + private void safeUnbindService(JobServiceConnection connection) { + if (connection != null && connection.isBound()) { + try { + context.unbindService(connection); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Error unbinding service: " + e.getMessage()); + } + } + } + + private void onJobFinishedMessage(JobInvocation jobInvocation, int result) { + synchronized (serviceConnections) { + JobServiceConnection connection = serviceConnections.remove(jobInvocation); + safeUnbindService(connection); + } + + jobFinishedCallback.onJobFinished(jobInvocation, result); + } + + private static class ResponseHandler extends Handler { + + /** + * We hold a WeakReference to the ExecutionDelegator because it holds a reference to a + * Service Context and Handlers are often kept in memory longer than you'd expect because + * any pending Messages can maintain references to them. + */ + private final WeakReference executionDelegatorReference; + + ResponseHandler(Looper looper, WeakReference executionDelegator) { + super(looper); + this.executionDelegatorReference = executionDelegator; + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case JOB_FINISHED: + if (msg.obj instanceof JobInvocation) { + ExecutionDelegator delegator = this.executionDelegatorReference.get(); + if (delegator == null) { + Log.wtf(TAG, "handleMessage: service was unexpectedly GC'd" + + ", can't send job result"); + return; + } + + delegator.onJobFinishedMessage((JobInvocation) msg.obj, msg.arg1); + return; + } + + Log.wtf(TAG, "handleMessage: unknown obj returned"); + return; + + default: + Log.wtf(TAG, "handleMessage: unknown message type received: " + msg.what); + } + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/FirebaseJobDispatcher.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/FirebaseJobDispatcher.java new file mode 100644 index 00000000..1a76093f --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/FirebaseJobDispatcher.java @@ -0,0 +1,211 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import com.firebase.jobdispatcher.RetryStrategy.RetryPolicy; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * The FirebaseJobDispatcher provides a driver-agnostic API for scheduling and cancelling Jobs. + * + * @see #FirebaseJobDispatcher(Driver) + * @see Driver + * @see JobParameters + */ +public final class FirebaseJobDispatcher { + /** + * Indicates the schedule request seems to have been successful. + */ + public final static int SCHEDULE_RESULT_SUCCESS = 0; + + /** + * Indicates the schedule request encountered an unknown error. + */ + public final static int SCHEDULE_RESULT_UNKNOWN_ERROR = 1; + + /** + * Indicates the schedule request failed because the driver was unavailable. + */ + public final static int SCHEDULE_RESULT_NO_DRIVER_AVAILABLE = 2; + + /** + * Indicates the schedule request failed because the Trigger was unsupported. + */ + public final static int SCHEDULE_RESULT_UNSUPPORTED_TRIGGER = 3; + + /** + * Indicates the schedule request failed because the service is not exposed or configured + * correctly. + */ + public final static int SCHEDULE_RESULT_BAD_SERVICE = 4; + + /** + * Indicates the cancel request seems to have been successful. + */ + public final static int CANCEL_RESULT_SUCCESS = 0; + /** + * Indicates the cancel request encountered an unknown error. + */ + public final static int CANCEL_RESULT_UNKNOWN_ERROR = 1; + /** + * Indicates the cancel request failed because the driver was unavailable. + */ + public final static int CANCEL_RESULT_NO_DRIVER_AVAILABLE = 2; + /** + * The backing Driver for this instance. + */ + private final Driver mDriver; + /** + * The ValidationEnforcer configured for the current Driver. + */ + private final ValidationEnforcer mValidator; + /** + * Single instance of a RetryStrategy.Builder, configured with the current driver's validation + * settings. We can do this because the RetryStrategy.Builder is stateless. + */ + private RetryStrategy.Builder mRetryStrategyBuilder; + + /** + * Instantiates a new FirebaseJobDispatcher using the provided Driver. + */ + public FirebaseJobDispatcher(Driver driver) { + mDriver = driver; + mValidator = new ValidationEnforcer(mDriver.getValidator()); + mRetryStrategyBuilder = new RetryStrategy.Builder(mValidator); + } + + /** + * Attempts to schedule the provided Job. + *

+ * Returns one of the SCHEDULE_RESULT_ constants. + */ + @ScheduleResult + public int schedule(@NonNull Job job) { + if (!mDriver.isAvailable()) { + return SCHEDULE_RESULT_NO_DRIVER_AVAILABLE; + } + + return mDriver.schedule(job); + } + + /** + * Attempts to cancel the Job that matches the provided tag and endpoint. + *

+ * Returns one of the CANCEL_RESULT_ constants. + */ + @CancelResult + public int cancel(@NonNull String tag) { + if (!mDriver.isAvailable()) { + return CANCEL_RESULT_NO_DRIVER_AVAILABLE; + } + + return mDriver.cancel(tag); + } + + /** + * Attempts to cancel all Jobs registered for this package. + *

+ * Returns one of the CANCEL_RESULT_ constants. + */ + @CancelResult + public int cancelAll() { + if (!mDriver.isAvailable()) { + return CANCEL_RESULT_NO_DRIVER_AVAILABLE; + } + + return mDriver.cancelAll(); + } + + /** + * Attempts to schedule the provided Job, throwing an exception if it fails. + * + * @throws ScheduleFailedException + */ + public void mustSchedule(Job job) { + if (schedule(job) != SCHEDULE_RESULT_SUCCESS) { + throw new ScheduleFailedException(); + } + } + + /** + * Returns a ValidationEnforcer configured for the current Driver. + */ + public ValidationEnforcer getValidator() { + return mValidator; + } + + /** + * Creates a new Job.Builder, configured with the current driver's validation settings. + */ + @NonNull + public Job.Builder newJobBuilder() { + return new Job.Builder(mValidator); + } + + /** + * Creates a new RetryStrategy from the provided parameters, validated with the current driver's + * {@link JobValidator}. + * + * @param policy the backoff policy to use. One of the {@link RetryPolicy} constants. + * @param initialBackoff the initial backoff, in seconds. + * @param maximumBackoff the maximum backoff, in seconds. + * @throws ValidationEnforcer.ValidationException + * @see RetryStrategy + */ + public RetryStrategy newRetryStrategy(@RetryPolicy int policy, int initialBackoff, + int maximumBackoff) { + + return mRetryStrategyBuilder.build(policy, initialBackoff, maximumBackoff); + } + + /** + * Results that can legally be returned from {@link #schedule(Job)} calls. + */ + @IntDef({ + SCHEDULE_RESULT_SUCCESS, + SCHEDULE_RESULT_UNKNOWN_ERROR, + SCHEDULE_RESULT_NO_DRIVER_AVAILABLE, + SCHEDULE_RESULT_UNSUPPORTED_TRIGGER, + SCHEDULE_RESULT_BAD_SERVICE, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ScheduleResult { + } + + /** + * Results that can legally be returned from {@link #cancel(String)} or {@link #cancelAll()} + * calls. + */ + @IntDef({ + CANCEL_RESULT_SUCCESS, + CANCEL_RESULT_UNKNOWN_ERROR, + CANCEL_RESULT_NO_DRIVER_AVAILABLE, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface CancelResult { + } + + /** + * Thrown when a {@link FirebaseJobDispatcher#schedule(com.firebase.jobdispatcher.Job)} call + * fails. + */ + public final static class ScheduleFailedException extends RuntimeException { + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayCallbackExtractor.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayCallbackExtractor.java new file mode 100644 index 00000000..03fca073 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayCallbackExtractor.java @@ -0,0 +1,247 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Nullable; +import android.util.Log; +import android.util.Pair; +import java.util.ArrayList; + +/** + * Responsible for extracting a JobCallback from a given Bundle. + * + *

Google Play services will send the Binder packed inside a simple strong Binder wrapper ({@link + * #PENDING_CALLBACK_CLASS}) under the key {@link #BUNDLE_KEY_CALLBACK "callback"}. + */ +/* package */ final class GooglePlayCallbackExtractor { + + private static final String TAG = GooglePlayReceiver.TAG; + private static final String ERROR_NULL_CALLBACK = "No callback received, terminating"; + private static final String ERROR_INVALID_CALLBACK = "Bad callback received, terminating"; + + /** The Parcelable class that wraps the Binder we need to access. */ + private static final String PENDING_CALLBACK_CLASS = + "com.google.android.gms.gcm.PendingCallback"; + /** The key for the wrapped Binder. */ + private static final String BUNDLE_KEY_CALLBACK = "callback"; + /** A magic number that indicates the following bytes belong to a Bundle. */ + private static final int BUNDLE_MAGIC = 0x4C444E42; + /** A magic number that indicates the following value is a Parcelable. */ + private static final int VAL_PARCELABLE = 4; + + // GuardedBy("GooglePlayCallbackExtractor.class") + private static Boolean shouldReadKeysAsStringsCached = null; + + public Pair extractCallback(@Nullable Bundle data) { + if (data == null) { + Log.e(TAG, ERROR_NULL_CALLBACK); + return null; + } + + return extractWrappedBinderFromParcel(data); + } + + /** + * Bundles are written out in the following format: + * A header, which consists of: + *

    + *
  1. length (int)
  2. + *
  3. magic number ({@link #BUNDLE_MAGIC}) (int)
  4. + *
  5. number of entries (int)
  6. + *
+ *

+ * Then the map values, each of which looks like this: + *

    + *
  1. string key
  2. + *
  3. int type marker
  4. + *
  5. (any) parceled value
  6. + *
+ *

+ * We're just going to iterate over the map looking for the right key (BUNDLE_KEY_CALLBACK) + * and try and read the IBinder straight from the parcelled data. This is entirely dependent + * on the implementation of Parcel, but these specific parts of Parcel / Bundle haven't + * changed since 2008 and newer versions of Android will ship with newer versions of Google + * Play services which embed the IBinder directly into the Bundle (no need to deal with the + * Parcelable issues). + */ + @Nullable + @SuppressLint("ParcelClassLoader") + private Pair extractWrappedBinderFromParcel(Bundle data) { + Bundle cleanBundle = new Bundle(); + Parcel serialized = toParcel(data); + JobCallback callback = null; + + try { + int length = serialized.readInt(); + if (length <= 0) { + // Empty Bundle + Log.w(TAG, ERROR_NULL_CALLBACK); + return null; + } + + int magic = serialized.readInt(); + if (magic != BUNDLE_MAGIC) { + // Not a Bundle + Log.w(TAG, ERROR_NULL_CALLBACK); + return null; + } + + int numEntries = serialized.readInt(); + for (int i = 0; i < numEntries; i++) { + String entryKey = readKey(serialized); + if (entryKey == null) { + continue; + } + + if (!(callback == null && BUNDLE_KEY_CALLBACK.equals(entryKey))) { + // If it's not the 'callback' key, we can just read it using the standard + // mechanisms because we're not afraid of rogue BadParcelableExceptions. + Object value = serialized.readValue(null /* class loader */); + if (value instanceof String) { + cleanBundle.putString(entryKey, (String) value); + } else if (value instanceof Boolean) { + cleanBundle.putBoolean(entryKey, (boolean) value); + } else if (value instanceof Integer) { + cleanBundle.putInt(entryKey, (int) value); + } else if (value instanceof ArrayList) { + // The only acceptable ArrayList in a Bundle is one that consists entirely + // of Parcelables, so this cast is safe. + @SuppressWarnings("unchecked") // safe by specification + ArrayList arrayList = (ArrayList) value; + cleanBundle.putParcelableArrayList(entryKey, arrayList); + } else if (value instanceof Bundle) { + cleanBundle.putBundle(entryKey, (Bundle) value); + } else if (value instanceof Parcelable) { + cleanBundle.putParcelable(entryKey, (Parcelable) value); + } + + // Move to the next key + continue; + } + + int typeTag = serialized.readInt(); + if (typeTag != VAL_PARCELABLE) { + // If the key is correct ("callback"), but it's not a Parcelable then something + // went wrong and we should bail. + Log.w(TAG, ERROR_INVALID_CALLBACK); + return null; + } + + String clsname = serialized.readString(); + if (!PENDING_CALLBACK_CLASS.equals(clsname)) { + // If it's a Parcelable, but not one we recognize then we should not try and + // unpack it. + Log.w(TAG, ERROR_INVALID_CALLBACK); + return null; + } + + // Instead of trying to instantiate clsname, we'll just read its single member. + IBinder remote = serialized.readStrongBinder(); + callback = new GooglePlayJobCallback(remote); + } + + if (callback == null) { + Log.w(TAG, ERROR_NULL_CALLBACK); + return null; + } + return Pair.create(callback, cleanBundle); + } finally { + serialized.recycle(); + } + } + + private static Parcel toParcel(Bundle data) { + Parcel serialized = Parcel.obtain(); + data.writeToParcel(serialized, 0); + serialized.setDataPosition(0); + return serialized; + } + + /** + * Reads the next key (String) from the provided {@code serialized} Parcel. + * + *

Naively using {@link Parcel#readString()} fails on versions of Android older than L, + * whereas {@link Parcel#readValue(ClassLoader)} works on older versions but fails on anything L + * or newer. + */ + private String readKey(Parcel serialized) { + if (shouldReadKeysAsStrings()) { + return serialized.readString(); + } + + // Older platforms require readValue + Object entryKeyObj = serialized.readValue(null /* Use the system ClassLoader */); + if (!(entryKeyObj instanceof String)) { + // Should never happen (Bundle keys are always Strings) + Log.w(TAG, ERROR_INVALID_CALLBACK); + return null; + } + + return (String) entryKeyObj; + } + + /** + * Checks whether {@link Parcel#readString()} or {@link Parcel#readValue()} should be used to + * access Bundle keys from a serialized Parcel. Commit {@link + * https://android.googlesource.com/platform/frameworks/base/+/9c3e74f + * I57bda9eb79ceaaa9c1b94ad49d9e462b52102149} (which only officially landed in Lollipop) changed + * from using writeValue to writeString for Bundle keys. Some OEMs have pulled this change into + * their KitKat fork, so we can't trust the SDK version check. Instead, we'll write a dummy + * Bundle to a Parcel and figure it out using that. + * + * The check is cached because the result can't change during runtime. + */ + private static synchronized boolean shouldReadKeysAsStrings() { + // We're pretty sure that readString() should always be used on L+, but if we shortcircuit + // this check then we have no evidence that this code is functioning correctly on KitKat + // devices that have the corresponding writeString() change. + if (shouldReadKeysAsStringsCached == null) { + final String expectedKey = "key"; + Bundle testBundle = new Bundle(); + testBundle.putString(expectedKey, "value"); + Parcel testParcel = toParcel(testBundle); + try { + // length + checkCondition(testParcel.readInt() > 0); + // magic + checkCondition(testParcel.readInt() == BUNDLE_MAGIC); + // num entries + checkCondition(testParcel.readInt() == 1); + + shouldReadKeysAsStringsCached = expectedKey.equals(testParcel.readString()); + } catch (RuntimeException e) { + shouldReadKeysAsStringsCached = Boolean.FALSE; + } finally { + testParcel.recycle(); + } + } + + return shouldReadKeysAsStringsCached; + } + + /** Throws an {@code IllegalStateException} if {@code condition} is false. */ + private static void checkCondition(boolean condition) { + if (!condition) { + throw new IllegalStateException(); + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayDriver.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayDriver.java new file mode 100644 index 00000000..58314821 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayDriver.java @@ -0,0 +1,162 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.support.annotation.NonNull; + +import com.firebase.jobdispatcher.FirebaseJobDispatcher.ScheduleResult; + +/** + * GooglePlayDriver provides an implementation of Driver for devices with Google Play + * services installed. This backend does not do any availability checks and any uses should be + * guarded with a call to {@code GoogleApiAvailability#isGooglePlayServicesAvailable(android.content.Context)} + * + * @see + * GoogleApiAvailability + */ +public final class GooglePlayDriver implements Driver { + static final String BACKEND_PACKAGE = "com.google.android.gms"; + private final static String ACTION_SCHEDULE = "com.google.android.gms.gcm.ACTION_SCHEDULE"; + + private final static String BUNDLE_PARAM_SCHEDULER_ACTION = "scheduler_action"; + private final static String BUNDLE_PARAM_TAG = "tag"; + private final static String BUNDLE_PARAM_TOKEN = "app"; + private final static String BUNDLE_PARAM_COMPONENT = "component"; + + private final static String SCHEDULER_ACTION_SCHEDULE_TASK = "SCHEDULE_TASK"; + private final static String SCHEDULER_ACTION_CANCEL_TASK = "CANCEL_TASK"; + private final static String SCHEDULER_ACTION_CANCEL_ALL = "CANCEL_ALL"; + private static final String INTENT_PARAM_SOURCE = "source"; + private static final String INTENT_PARAM_SOURCE_VERSION = "source_version"; + + private static final int JOB_DISPATCHER_SOURCE_CODE = 1 << 3; + private static final int JOB_DISPATCHER_SOURCE_VERSION_CODE = 1; + + private final JobValidator mValidator; + /** + * The application Context. Used to send broadcasts. + */ + private final Context mContext; + /** + * A PendingIntent from this package. Passed inside the broadcast so the receiver can verify the + * sender's package. + */ + private final PendingIntent mToken; + /** + * Turns Jobs into Bundles. + */ + private final GooglePlayJobWriter mWriter; + + /** + * Instantiates a new GooglePlayDriver. + */ + public GooglePlayDriver(Context context) { + mContext = context; + mToken = PendingIntent.getBroadcast(context, 0, new Intent(), 0); + mWriter = new GooglePlayJobWriter(); + mValidator = new DefaultJobValidator(context); + } + + @Override + public boolean isAvailable() { + ApplicationInfo applicationInfo = null; + try { + applicationInfo = mContext.getPackageManager().getApplicationInfo(BACKEND_PACKAGE, 0); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + return applicationInfo != null && applicationInfo.enabled; + } + + + /** + * Schedules the provided Job. + */ + @Override + @ScheduleResult + public int schedule(@NonNull Job job) { + mContext.sendBroadcast(createScheduleRequest(job)); + + return FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS; + } + + @Override + public int cancel(@NonNull String tag) { + mContext.sendBroadcast(createCancelRequest(tag)); + + return FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS; + } + + @Override + public int cancelAll() { + mContext.sendBroadcast(createBatchCancelRequest()); + + return FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS; + } + + @NonNull + protected Intent createCancelRequest(@NonNull String tag) { + Intent cancelReq = createSchedulerIntent(SCHEDULER_ACTION_CANCEL_TASK); + cancelReq.putExtra(BUNDLE_PARAM_TAG, tag); + cancelReq.putExtra(BUNDLE_PARAM_COMPONENT, new ComponentName(mContext, getReceiverClass())); + return cancelReq; + } + + @NonNull + protected Intent createBatchCancelRequest() { + Intent cancelReq = createSchedulerIntent(SCHEDULER_ACTION_CANCEL_ALL); + cancelReq.putExtra(BUNDLE_PARAM_COMPONENT, new ComponentName(mContext, getReceiverClass())); + return cancelReq; + } + + @NonNull + protected Class getReceiverClass() { + return GooglePlayReceiver.class; + } + + @NonNull + @Override + public JobValidator getValidator() { + return mValidator; + } + + @NonNull + private Intent createScheduleRequest(JobParameters job) { + Intent scheduleReq = createSchedulerIntent(SCHEDULER_ACTION_SCHEDULE_TASK); + scheduleReq.putExtras(mWriter.writeToBundle(job, scheduleReq.getExtras())); + return scheduleReq; + } + + @NonNull + private Intent createSchedulerIntent(String schedulerAction) { + Intent scheduleReq = new Intent(ACTION_SCHEDULE); + + scheduleReq.setPackage(BACKEND_PACKAGE); + scheduleReq.putExtra(BUNDLE_PARAM_SCHEDULER_ACTION, schedulerAction); + scheduleReq.putExtra(BUNDLE_PARAM_TOKEN, mToken); + scheduleReq.putExtra(INTENT_PARAM_SOURCE, JOB_DISPATCHER_SOURCE_CODE); + scheduleReq.putExtra(INTENT_PARAM_SOURCE_VERSION, JOB_DISPATCHER_SOURCE_VERSION_CODE); + + return scheduleReq; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayJobCallback.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayJobCallback.java new file mode 100644 index 00000000..7f47fe59 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayJobCallback.java @@ -0,0 +1,56 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.os.IBinder; +import android.os.Parcel; +import android.os.RemoteException; + +/** + * Wraps the GooglePlay-specific callback class in a JobCallback-compatible interface. + */ +/* package */ final class GooglePlayJobCallback implements JobCallback { + + private static final String DESCRIPTOR = "com.google.android.gms.gcm.INetworkTaskCallback"; + /** The only supported transaction ID. */ + private static final int TRANSACTION_TASK_FINISHED = IBinder.FIRST_CALL_TRANSACTION + 1; + + private final IBinder mRemote; + + public GooglePlayJobCallback(IBinder binder) { + mRemote = binder; + } + + @Override + public void jobFinished(@JobService.JobResult int status) { + Parcel request = Parcel.obtain(); + Parcel response = Parcel.obtain(); + try { + request.writeInterfaceToken(DESCRIPTOR); + request.writeInt(status); + + mRemote.transact(TRANSACTION_TASK_FINISHED, request, response, 0); + + response.readException(); + } catch (RemoteException e) { + throw new RuntimeException(e); + } finally { + request.recycle(); + response.recycle(); + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayJobWriter.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayJobWriter.java new file mode 100644 index 00000000..0389f90f --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayJobWriter.java @@ -0,0 +1,198 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.IntDef; +import android.support.annotation.VisibleForTesting; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; +import com.firebase.jobdispatcher.RetryStrategy.RetryPolicy; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/* package */ final class GooglePlayJobWriter { + + static final String REQUEST_PARAM_UPDATE_CURRENT = "update_current"; + static final String REQUEST_PARAM_EXTRAS = "extras"; + static final String REQUEST_PARAM_PERSISTED = "persisted"; + static final String REQUEST_PARAM_REQUIRED_NETWORK = "requiredNetwork"; + static final String REQUEST_PARAM_REQUIRES_CHARGING = "requiresCharging"; + static final String REQUEST_PARAM_REQUIRES_IDLE = "requiresIdle"; + static final String REQUEST_PARAM_RETRY_STRATEGY = "retryStrategy"; + static final String REQUEST_PARAM_SERVICE = "service"; + static final String REQUEST_PARAM_TAG = "tag"; + + static final String REQUEST_PARAM_RETRY_STRATEGY_INITIAL_BACKOFF_SECONDS = + "initial_backoff_seconds"; + static final String REQUEST_PARAM_RETRY_STRATEGY_MAXIMUM_BACKOFF_SECONDS = + "maximum_backoff_seconds"; + static final String REQUEST_PARAM_RETRY_STRATEGY_POLICY = "retry_policy"; + + static final String REQUEST_PARAM_TRIGGER_TYPE = "trigger_type"; + static final String REQUEST_PARAM_TRIGGER_WINDOW_END = "window_end"; + static final String REQUEST_PARAM_TRIGGER_WINDOW_FLEX = "period_flex"; + static final String REQUEST_PARAM_TRIGGER_WINDOW_PERIOD = "period"; + static final String REQUEST_PARAM_TRIGGER_WINDOW_START = "window_start"; + + @VisibleForTesting + /* package */ static final int LEGACY_RETRY_POLICY_EXPONENTIAL = 0; + @VisibleForTesting + /* package */ static final int LEGACY_RETRY_POLICY_LINEAR = 1; + @VisibleForTesting + /* package */ final static int LEGACY_NETWORK_UNMETERED = 1; + @VisibleForTesting + /* package */ final static int LEGACY_NETWORK_CONNECTED = 0; + @VisibleForTesting + /* package */ final static int LEGACY_NETWORK_ANY = 2; + + private JobCoder jobCoder = new JobCoder(BundleProtocol.PACKED_PARAM_BUNDLE_PREFIX, false); + + private static void writeExecutionWindowTriggerToBundle(JobParameters job, Bundle b, + JobTrigger.ExecutionWindowTrigger trigger) { + + b.putInt(REQUEST_PARAM_TRIGGER_TYPE, BundleProtocol.TRIGGER_TYPE_EXECUTION_WINDOW); + + if (job.isRecurring()) { + b.putLong(REQUEST_PARAM_TRIGGER_WINDOW_PERIOD, + trigger.getWindowEnd()); + b.putLong(REQUEST_PARAM_TRIGGER_WINDOW_FLEX, + trigger.getWindowEnd() - trigger.getWindowStart()); + } else { + b.putLong(REQUEST_PARAM_TRIGGER_WINDOW_START, + trigger.getWindowStart()); + b.putLong(REQUEST_PARAM_TRIGGER_WINDOW_END, + trigger.getWindowEnd()); + } + } + + private static void writeImmediateTriggerToBundle(Bundle b) { + b.putInt(REQUEST_PARAM_TRIGGER_TYPE, BundleProtocol.TRIGGER_TYPE_IMMEDIATE); + b.putLong(REQUEST_PARAM_TRIGGER_WINDOW_START, 0); + b.putLong(REQUEST_PARAM_TRIGGER_WINDOW_END, 30); + } + + private void writeContentUriTriggerToBundle(Bundle data, ContentUriTrigger uriTrigger) { + data.putInt(BundleProtocol.PACKED_PARAM_TRIGGER_TYPE, + BundleProtocol.TRIGGER_TYPE_CONTENT_URI); + + int size = uriTrigger.getUris().size(); + int[] flagsArray = new int[size]; + Uri[] uriArray = new Uri[size]; + for (int i = 0; i < size; i++) { + ObservedUri uri = uriTrigger.getUris().get(i); + flagsArray[i] = uri.getFlags(); + uriArray[i] = uri.getUri(); + } + data.putIntArray(BundleProtocol.PACKED_PARAM_CONTENT_URI_FLAGS_ARRAY, flagsArray); + data.putParcelableArray(BundleProtocol.PACKED_PARAM_CONTENT_URI_ARRAY, uriArray); + } + + public Bundle writeToBundle(JobParameters job, Bundle b) { + b.putString(REQUEST_PARAM_TAG, job.getTag()); + b.putBoolean(REQUEST_PARAM_UPDATE_CURRENT, job.shouldReplaceCurrent()); + + boolean persisted = job.getLifetime() == Lifetime.FOREVER; + b.putBoolean(REQUEST_PARAM_PERSISTED, persisted); + b.putString(REQUEST_PARAM_SERVICE, GooglePlayReceiver.class.getName()); + + writeTriggerToBundle(job, b); + writeConstraintsToBundle(job, b); + writeRetryStrategyToBundle(job, b); + + // Embed the job spec (minus extras) into the extras (under a prefix) + Bundle extras = job.getExtras(); + if (extras == null) { + extras = new Bundle(); + } + b.putBundle(REQUEST_PARAM_EXTRAS, jobCoder.encode(job, extras)); + + return b; + } + + private void writeRetryStrategyToBundle(JobParameters job, Bundle b) { + RetryStrategy strategy = job.getRetryStrategy(); + + Bundle rb = new Bundle(); + rb.putInt(REQUEST_PARAM_RETRY_STRATEGY_POLICY, + convertRetryPolicyToLegacyVersion(strategy.getPolicy())); + rb.putInt(REQUEST_PARAM_RETRY_STRATEGY_INITIAL_BACKOFF_SECONDS, + strategy.getInitialBackoff()); + rb.putInt(REQUEST_PARAM_RETRY_STRATEGY_MAXIMUM_BACKOFF_SECONDS, + strategy.getMaximumBackoff()); + + b.putBundle(REQUEST_PARAM_RETRY_STRATEGY, rb); + } + + private int convertRetryPolicyToLegacyVersion(@RetryPolicy int policy) { + switch (policy) { + case RetryStrategy.RETRY_POLICY_LINEAR: + return LEGACY_RETRY_POLICY_LINEAR; + + case RetryStrategy.RETRY_POLICY_EXPONENTIAL: + // fallthrough + default: + return LEGACY_RETRY_POLICY_EXPONENTIAL; + } + } + + private void writeTriggerToBundle(JobParameters job, Bundle b) { + final JobTrigger trigger = job.getTrigger(); + + if (trigger == Trigger.NOW) { + writeImmediateTriggerToBundle(b); + } else if (trigger instanceof JobTrigger.ExecutionWindowTrigger) { + writeExecutionWindowTriggerToBundle(job, b, (JobTrigger.ExecutionWindowTrigger) trigger); + } else if (trigger instanceof JobTrigger.ContentUriTrigger) { + writeContentUriTriggerToBundle(b, (JobTrigger.ContentUriTrigger) trigger); + } else { + throw new IllegalArgumentException("Unknown trigger: " + trigger.getClass()); + } + } + + private void writeConstraintsToBundle(JobParameters job, Bundle b) { + int c = Constraint.compact(job.getConstraints()); + + b.putBoolean(REQUEST_PARAM_REQUIRES_CHARGING, + (c & Constraint.DEVICE_CHARGING) == Constraint.DEVICE_CHARGING); + b.putBoolean(REQUEST_PARAM_REQUIRES_IDLE, + (c & Constraint.DEVICE_IDLE) == Constraint.DEVICE_IDLE); + b.putInt(REQUEST_PARAM_REQUIRED_NETWORK, convertConstraintsToLegacyNetConstant(c)); + } + + /** + * Converts a bitmap of Constraint values into a LegacyNetworkConstraint constant (int). + */ + @LegacyNetworkConstant + private int convertConstraintsToLegacyNetConstant(int constraintMap) { + int reqNet = LEGACY_NETWORK_ANY; + + reqNet = (constraintMap & Constraint.ON_ANY_NETWORK) == Constraint.ON_ANY_NETWORK + ? LEGACY_NETWORK_CONNECTED + : reqNet; + + reqNet = (constraintMap & Constraint.ON_UNMETERED_NETWORK) == Constraint.ON_UNMETERED_NETWORK + ? LEGACY_NETWORK_UNMETERED + : reqNet; + + return reqNet; + } + + @IntDef({LEGACY_NETWORK_ANY, LEGACY_NETWORK_CONNECTED, LEGACY_NETWORK_UNMETERED}) + @Retention(RetentionPolicy.SOURCE) + private @interface LegacyNetworkConstant {} +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayMessageHandler.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayMessageHandler.java new file mode 100644 index 00000000..664805f6 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayMessageHandler.java @@ -0,0 +1,114 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.GooglePlayJobWriter.REQUEST_PARAM_TAG; +import static com.firebase.jobdispatcher.GooglePlayReceiver.TAG; + +import android.annotation.TargetApi; +import android.app.AppOpsManager; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.Messenger; +import android.util.Log; +import com.firebase.jobdispatcher.JobInvocation.Builder; + +/** + * A messenger for communication with GCM Network Scheduler. + */ +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +class GooglePlayMessageHandler extends Handler { + + static final int MSG_START_EXEC = 1; + static final int MSG_STOP_EXEC = 2; + static final int MSG_RESULT = 3; + private static final int MSG_INIT = 4; + private final GooglePlayReceiver googlePlayReceiver; + + public GooglePlayMessageHandler(Looper mainLooper, GooglePlayReceiver googlePlayReceiver) { + super(mainLooper); + this.googlePlayReceiver = googlePlayReceiver; + } + + @Override + public void handleMessage(Message message) { + if (message == null) { + return; + } + + AppOpsManager appOpsManager = (AppOpsManager) googlePlayReceiver.getApplicationContext() + .getSystemService(Context.APP_OPS_SERVICE); + try { + appOpsManager.checkPackage(message.sendingUid, GooglePlayDriver.BACKEND_PACKAGE); + } catch (SecurityException e) { + Log.e(TAG, "Message was not sent from GCM."); + return; + } + + switch (message.what) { + case MSG_START_EXEC: + handleStartMessage(message); + break; + + case MSG_STOP_EXEC: + handleStopMessage(message); + break; + + case MSG_INIT: + // Not implemented. + break; + + default: + Log.e(TAG, "Unrecognized message received: " + message); + break; + } + } + + private void handleStartMessage(Message message) { + final Bundle data = message.getData(); + + final Messenger replyTo = message.replyTo; + String tag = data.getString(REQUEST_PARAM_TAG); + if (replyTo == null || tag == null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Invalid start execution message."); + } + return; + } + + GooglePlayMessengerCallback messengerCallback = + new GooglePlayMessengerCallback(replyTo, tag); + JobInvocation jobInvocation = googlePlayReceiver.prepareJob(messengerCallback, data); + googlePlayReceiver.getExecutionDelegator().executeJob(jobInvocation); + } + + private void handleStopMessage(Message message) { + Builder builder = GooglePlayReceiver.getJobCoder().decode(message.getData()); + if (builder == null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Invalid stop execution message."); + } + return; + } + JobInvocation job = builder.build(); + googlePlayReceiver.getExecutionDelegator().stopJob(job); + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayMessengerCallback.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayMessengerCallback.java new file mode 100644 index 00000000..06fb153f --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayMessengerCallback.java @@ -0,0 +1,61 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.GooglePlayJobWriter.REQUEST_PARAM_TAG; + +import android.os.Bundle; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import android.support.annotation.NonNull; +import com.firebase.jobdispatcher.JobService.JobResult; + +/** + * Wraps the GooglePlay messenger in a JobCallback-compatible interface. + */ +class GooglePlayMessengerCallback implements JobCallback { + + private final Messenger messenger; + private final String tag; + + GooglePlayMessengerCallback(Messenger messenger, String tag) { + this.messenger = messenger; + this.tag = tag; + } + + @Override + public void jobFinished(@JobResult int status) { + try { + messenger.send(createResultMessage(status)); + } catch (RemoteException e) { + throw new RuntimeException(e); + } + } + + @NonNull + private Message createResultMessage(int result) { + final Message msg = Message.obtain(); + msg.what = GooglePlayMessageHandler.MSG_RESULT; + msg.arg1 = result; + + Bundle b = new Bundle(); + b.putString(REQUEST_PARAM_TAG, tag); + msg.setData(b); + return msg; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayReceiver.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayReceiver.java new file mode 100644 index 00000000..e8dc6060 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/GooglePlayReceiver.java @@ -0,0 +1,274 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Looper; +import android.os.Messenger; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.util.SimpleArrayMap; +import android.util.Log; +import android.util.Pair; +import com.firebase.jobdispatcher.Job.Builder; +import com.firebase.jobdispatcher.JobService.JobResult; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; + +/** + * Handles incoming execute requests from the GooglePlay driver and forwards them to your Service. + */ +public class GooglePlayReceiver extends Service implements ExecutionDelegator.JobFinishedCallback { + /** + * Logging tag. + */ + /* package */ static final String TAG = "FJD.GooglePlayReceiver"; + /** + * The action sent by Google Play services that triggers job execution. + */ + @VisibleForTesting + static final String ACTION_EXECUTE = "com.google.android.gms.gcm.ACTION_TASK_READY"; + + /** Action sent by Google Play services when your app has been updated. */ + @VisibleForTesting + static final String ACTION_INITIALIZE = "com.google.android.gms.gcm.SERVICE_ACTION_INITIALIZE"; + + private static final String ERROR_NULL_INTENT = "Null Intent passed, terminating"; + private static final String ERROR_UNKNOWN_ACTION = "Unknown action received, terminating"; + private static final String ERROR_NO_DATA = "No data provided, terminating"; + + private static final JobCoder prefixedCoder = + new JobCoder(BundleProtocol.PACKED_PARAM_BUNDLE_PREFIX, true); + + private final GooglePlayCallbackExtractor callbackExtractor = new GooglePlayCallbackExtractor(); + + /** + * The single Messenger that's returned from valid onBind requests. Guarded by intrinsic lock. + */ + @VisibleForTesting + Messenger serviceMessenger; + + /** + * Driver for rescheduling jobs. Guarded by intrinsic lock. + */ + @VisibleForTesting + Driver driver; + + /** + * Guarded by intrinsic lock. + */ + @VisibleForTesting + ValidationEnforcer validationEnforcer; + + /** + * The ExecutionDelegator used to communicate with client JobServices. + * Guarded by intrinsic lock. + */ + private ExecutionDelegator executionDelegator; + + /** + * The most recent startId passed to onStartCommand. + * Guarded by intrinsic lock. + */ + private int latestStartId; + + /** + * Endpoint (String) -> Tag (String) -> JobCallback + */ + private SimpleArrayMap> callbacks = + new SimpleArrayMap<>(1); + + private static void sendResultSafely(JobCallback callback, int result) { + try { + callback.jobFinished(result); + } catch (Throwable e) { + Log.e(TAG, "Encountered error running callback", e.getCause()); + } + } + + @Override + public final int onStartCommand(Intent intent, int flags, int startId) { + try { + super.onStartCommand(intent, flags, startId); + + if (intent == null) { + Log.w(TAG, ERROR_NULL_INTENT); + return START_NOT_STICKY; + } + + String action = intent.getAction(); + if (ACTION_EXECUTE.equals(action)) { + getExecutionDelegator().executeJob(prepareJob(intent)); + return START_NOT_STICKY; + } else if (ACTION_INITIALIZE.equals(action)) { + return START_NOT_STICKY; + } + + Log.e(TAG, ERROR_UNKNOWN_ACTION); + return START_NOT_STICKY; + } finally { + synchronized (this) { + latestStartId = startId; + if (callbacks.isEmpty()) { + stopSelf(latestStartId); + } + } + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + // Only Lollipop+ supports UID checking messages, so we can't trust this system on older + // platforms. + if (intent == null + || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP + || !ACTION_EXECUTE.equals(intent.getAction())) { + return null; + } + return getServiceMessenger().getBinder(); + } + + private synchronized Messenger getServiceMessenger() { + if (serviceMessenger == null) { + serviceMessenger = + new Messenger(new GooglePlayMessageHandler(Looper.getMainLooper(), this)); + } + return serviceMessenger; + } + + /* package */ synchronized ExecutionDelegator getExecutionDelegator() { + if (executionDelegator == null) { + executionDelegator = new ExecutionDelegator(this, this); + } + return executionDelegator; + } + + @NonNull + private synchronized Driver getGooglePlayDriver() { + if (driver == null) { + driver = new GooglePlayDriver(getApplicationContext()); + } + return driver; + } + + @NonNull + private synchronized ValidationEnforcer getValidationEnforcer() { + if (validationEnforcer == null) { + validationEnforcer = new ValidationEnforcer(getGooglePlayDriver().getValidator()); + } + return validationEnforcer; + } + + @Nullable + @VisibleForTesting + JobInvocation prepareJob(Intent intent) { + Bundle intentExtras = intent.getExtras(); + if (intentExtras == null) { + Log.e(TAG, ERROR_NO_DATA); + return null; + } + + // get the callback first. If we don't have this we can't talk back to the backend. + Pair extraction = callbackExtractor.extractCallback(intentExtras); + if (extraction == null) { + Log.i(TAG, "no callback found"); + return null; + } + return prepareJob(extraction.first, extraction.second); + } + + @Nullable + synchronized JobInvocation prepareJob(JobCallback callback, Bundle bundle) { + JobInvocation job = prefixedCoder.decodeIntentBundle(bundle); + if (job == null) { + Log.e(TAG, "unable to decode job"); + sendResultSafely(callback, JobService.RESULT_FAIL_NORETRY); + return null; + } + SimpleArrayMap map = callbacks.get(job.getService()); + if (map == null) { + map = new SimpleArrayMap<>(1); + callbacks.put(job.getService(), map); + } + + map.put(job.getTag(), callback); + + return job; + } + + @Override + public synchronized void onJobFinished(@NonNull JobInvocation js, @JobResult int result) { + try { + SimpleArrayMap map = callbacks.get(js.getService()); + if (map == null) { + return; + } + JobCallback callback = map.remove(js.getTag()); + if (callback == null) { + return; + } + if (map.isEmpty()) { + callbacks.remove(js.getService()); + } + + if (needsToBeRescheduled(js, result)) { + reschedule(js); + } else { + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "sending jobFinished for " + js.getTag() + " = " + result); + } + sendResultSafely(callback, result); + } + } finally { + if (callbacks.isEmpty()) { + // Safe to call stopSelf, even if we're being bound to + stopSelf(latestStartId); + } + } + } + + private void reschedule(JobInvocation jobInvocation) { + Job job = new Builder(getValidationEnforcer(), jobInvocation) + .setReplaceCurrent(true) + .build(); + + getGooglePlayDriver().schedule(job); + } + + /** + * Recurring content URI triggered jobs need to be rescheduled when execution is finished. + * + *

GooglePlay does not support recurring content URI triggered jobs. + * + *

{@link JobService#RESULT_FAIL_RETRY} needs to be sent or current triggered URIs will be + * lost. + */ + private static boolean needsToBeRescheduled(JobParameters job, int result) { + return job.isRecurring() + && job.getTrigger() instanceof ContentUriTrigger + && result != JobService.RESULT_FAIL_RETRY; + } + + static JobCoder getJobCoder() { + return prefixedCoder; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Job.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Job.java new file mode 100644 index 00000000..692404fe --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Job.java @@ -0,0 +1,378 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.firebase.jobdispatcher.Constraint.JobConstraint; + +/** + * Job is the embodiment of a unit of work and an associated set of triggers, settings, and runtime + * constraints. + */ +public final class Job implements JobParameters { + private final String mService; + private final String mTag; + private final JobTrigger mTrigger; + private final RetryStrategy mRetryStrategy; + private final int mLifetime; + private final boolean mRecurring; + private final int[] mConstraints; + private final boolean mReplaceCurrent; + private Bundle mExtras; + + private Job(Builder builder) { + mService = builder.mServiceClassName; + mExtras = builder.mExtras; + mTag = builder.mTag; + mTrigger = builder.mTrigger; + mRetryStrategy = builder.mRetryStrategy; + mLifetime = builder.mLifetime; + mRecurring = builder.mRecurring; + mConstraints = builder.mConstraints != null ? builder.mConstraints : new int[0]; + mReplaceCurrent = builder.mReplaceCurrent; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public int[] getConstraints() { + return mConstraints; + } + + /** + * {@inheritDoc} + */ + @Nullable + @Override + public Bundle getExtras() { + return mExtras; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public RetryStrategy getRetryStrategy() { + return mRetryStrategy; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean shouldReplaceCurrent() { + return mReplaceCurrent; + } + + @Nullable + @Override + public TriggerReason getTriggerReason() { + return null; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public String getTag() { + return mTag; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public JobTrigger getTrigger() { + return mTrigger; + } + + /** + * {@inheritDoc} + */ + @Override + public int getLifetime() { + return mLifetime; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isRecurring() { + return mRecurring; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public String getService() { + return mService; + } + + /** + * A class that understands how to build a {@link Job}. Retrieved by calling + * {@link FirebaseJobDispatcher#newJobBuilder()}. + */ + public final static class Builder implements JobParameters { + private final ValidationEnforcer mValidator; + + private String mServiceClassName; + private Bundle mExtras; + private String mTag; + private JobTrigger mTrigger = Trigger.NOW; + private int mLifetime = Lifetime.UNTIL_NEXT_BOOT; + private int[] mConstraints; + + private RetryStrategy mRetryStrategy = RetryStrategy.DEFAULT_EXPONENTIAL; + private boolean mReplaceCurrent = false; + private boolean mRecurring = false; + + Builder(ValidationEnforcer validator) { + mValidator = validator; + } + + Builder(ValidationEnforcer validator, JobParameters job) { + mValidator = validator; + + mTag = job.getTag(); + mServiceClassName = job.getService(); + mTrigger = job.getTrigger(); + mRecurring = job.isRecurring(); + mLifetime = job.getLifetime(); + mConstraints = job.getConstraints(); + mExtras = job.getExtras(); + mRetryStrategy = job.getRetryStrategy(); + } + + /** + * Adds the provided constraint to the current list of runtime constraints. + */ + public Builder addConstraint(@JobConstraint int constraint) { + // Create a new, longer constraints array + int[] newConstraints = new int[mConstraints == null ? 1 : mConstraints.length + 1]; + + if (mConstraints != null && mConstraints.length != 0) { + // Copy all the old values over + System.arraycopy(mConstraints, 0, newConstraints, 0, mConstraints.length); + } + + // add the new value + newConstraints[newConstraints.length - 1] = constraint; + // update the pointer + mConstraints = newConstraints; + + return this; + } + + /** + * Sets whether this Job should replace pre-existing Jobs with the same tag. + */ + public Builder setReplaceCurrent(boolean replaceCurrent) { + mReplaceCurrent = replaceCurrent; + + return this; + } + + /** + * Builds the Job, using the settings provided so far. + * + * @throws ValidationEnforcer.ValidationException + */ + public Job build() { + mValidator.ensureValid(this); + + return new Job(this); + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public String getService() { + return mServiceClassName; + } + + /** + * Sets the backing JobService class for the Job. See {@link #getService()}. + */ + public Builder setService(Class serviceClass) { + mServiceClassName = serviceClass == null ? null : serviceClass.getName(); + + return this; + } + + /** + * Sets the backing JobService class name for the Job. See {@link #getService()}. + * + *

Should not be exposed, for internal use only. + */ + Builder setServiceName(String serviceClassName) { + mServiceClassName = serviceClassName; + + return this; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public String getTag() { + return mTag; + } + + /** + * Sets the unique String tag used to identify the Job. See {@link #getTag()}. + */ + public Builder setTag(String tag) { + mTag = tag; + + return this; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public JobTrigger getTrigger() { + return mTrigger; + } + + /** + * Sets the Trigger used for the Job. See {@link #getTrigger()}. + */ + public Builder setTrigger(JobTrigger trigger) { + mTrigger = trigger; + + return this; + } + + /** + * {@inheritDoc} + */ + @Override + @Lifetime.LifetimeConstant + public int getLifetime() { + return mLifetime; + } + + /** + * Sets the Job's lifetime, or how long it should persist. See {@link #getLifetime()}. + */ + public Builder setLifetime(@Lifetime.LifetimeConstant int lifetime) { + mLifetime = lifetime; + + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isRecurring() { + return mRecurring; + } + + /** + * Sets whether the job should recur. The default is false. + */ + public Builder setRecurring(boolean recurring) { + mRecurring = recurring; + + return this; + } + + /** + * {@inheritDoc} + */ + @Override + @JobConstraint + public int[] getConstraints() { + return mConstraints == null ? new int[]{} : mConstraints; + } + + /** + * Sets the Job's runtime constraints. See {@link #getConstraints()}. + */ + public Builder setConstraints(@JobConstraint int... constraints) { + mConstraints = constraints; + + return this; + } + + /** + * {@inheritDoc} + */ + @Nullable + @Override + public Bundle getExtras() { + return mExtras; + } + + /** + * Sets the user-defined extras associated with the Job. See {@link #getExtras()}. + */ + public Builder setExtras(Bundle extras) { + mExtras = extras; + + return this; + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public RetryStrategy getRetryStrategy() { + return mRetryStrategy; + } + + /** + * Set the RetryStrategy used for the Job. See {@link #getRetryStrategy()}. + */ + public Builder setRetryStrategy(RetryStrategy retryStrategy) { + mRetryStrategy = retryStrategy; + + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean shouldReplaceCurrent() { + return mReplaceCurrent; + } + + @Nullable + @Override + public TriggerReason getTriggerReason() { + return null; + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobCallback.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobCallback.java new file mode 100644 index 00000000..adeb82ea --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobCallback.java @@ -0,0 +1,28 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +/** + * JobCallback describes an object that knows how to send a JobResult back to the underlying + * execution driver. + */ +public interface JobCallback { + /** + * @throws RuntimeException + */ + void jobFinished(@JobService.JobResult int status); +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobCoder.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobCoder.java new file mode 100644 index 00000000..e5523587 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobCoder.java @@ -0,0 +1,253 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.Constraint.compact; +import static com.firebase.jobdispatcher.Constraint.uncompact; +import static com.firebase.jobdispatcher.ExecutionDelegator.TAG; + +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * JobCoder is a tool to encode and decode JobSpecs from Bundles. + */ +/* package */ final class JobCoder { + private final boolean includeExtras; + private final String prefix; + + private static final String JSON_URI_FLAGS = "uri_flags"; + private static final String JSON_URIS = "uris"; + + JobCoder(String prefix, boolean includeExtras) { + this.includeExtras = includeExtras; + this.prefix = prefix; + } + + @NonNull + Bundle encode(@NonNull JobParameters jobParameters, @NonNull Bundle data) { + if (data == null) { + throw new IllegalArgumentException("Unexpected null Bundle provided"); + } + + data.putInt(prefix + BundleProtocol.PACKED_PARAM_LIFETIME, + jobParameters.getLifetime()); + data.putBoolean(prefix + BundleProtocol.PACKED_PARAM_RECURRING, + jobParameters.isRecurring()); + data.putBoolean(prefix + BundleProtocol.PACKED_PARAM_REPLACE_CURRENT, + jobParameters.shouldReplaceCurrent()); + data.putString(prefix + BundleProtocol.PACKED_PARAM_TAG, + jobParameters.getTag()); + data.putString(prefix + BundleProtocol.PACKED_PARAM_SERVICE, + jobParameters.getService()); + data.putInt(prefix + BundleProtocol.PACKED_PARAM_CONSTRAINTS, + compact(jobParameters.getConstraints())); + + if (includeExtras) { + data.putBundle(prefix + BundleProtocol.PACKED_PARAM_EXTRAS, + jobParameters.getExtras()); + } + + encodeTrigger(jobParameters.getTrigger(), data); + encodeRetryStrategy(jobParameters.getRetryStrategy(), data); + + return data; + } + + JobInvocation decodeIntentBundle(@NonNull Bundle bundle) { + if (bundle == null) { + Log.e(TAG, "Unexpected null Bundle provided"); + return null; + } + + Bundle taskExtras = bundle.getBundle(GooglePlayJobWriter.REQUEST_PARAM_EXTRAS); + if (taskExtras == null) { + return null; + } + + JobInvocation.Builder builder = decode(taskExtras); + + List triggeredContentUris = + bundle.getParcelableArrayList(BundleProtocol.PACKED_PARAM_TRIGGERED_URIS); + if (triggeredContentUris != null) { + builder.setTriggerReason(new TriggerReason(triggeredContentUris)); + } + return builder.build(); + } + + @Nullable + public JobInvocation.Builder decode(@NonNull Bundle data) { + if (data == null) { + throw new IllegalArgumentException("Unexpected null Bundle provided"); + } + + boolean recur = data.getBoolean(prefix + BundleProtocol.PACKED_PARAM_RECURRING); + boolean replaceCur = data.getBoolean(prefix + BundleProtocol.PACKED_PARAM_REPLACE_CURRENT); + int lifetime = data.getInt(prefix + BundleProtocol.PACKED_PARAM_LIFETIME); + int[] constraints = uncompact(data.getInt(prefix + BundleProtocol.PACKED_PARAM_CONSTRAINTS)); + + JobTrigger trigger = decodeTrigger(data); + RetryStrategy retryStrategy = decodeRetryStrategy(data); + + String tag = data.getString(prefix + BundleProtocol.PACKED_PARAM_TAG); + String service = data.getString(prefix + BundleProtocol.PACKED_PARAM_SERVICE); + + if (tag == null || service == null || trigger == null || retryStrategy == null) { + return null; + } + + JobInvocation.Builder builder = new JobInvocation.Builder(); + builder.setTag(tag); + builder.setService(service); + builder.setTrigger(trigger); + builder.setRetryStrategy(retryStrategy); + builder.setRecurring(recur); + //noinspection WrongConstant + builder.setLifetime(lifetime); + //noinspection WrongConstant + builder.setConstraints(constraints); + builder.setReplaceCurrent(replaceCur); + + // repack the taskExtras + builder.addExtras(data); + return builder; + } + + @NonNull + private JobTrigger decodeTrigger(Bundle data) { + switch (data.getInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_TYPE)) { + case BundleProtocol.TRIGGER_TYPE_IMMEDIATE: + return Trigger.NOW; + + case BundleProtocol.TRIGGER_TYPE_EXECUTION_WINDOW: + return Trigger.executionWindow( + data.getInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_WINDOW_START), + data.getInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_WINDOW_END)); + + case BundleProtocol.TRIGGER_TYPE_CONTENT_URI: + String uris = data.getString(prefix + BundleProtocol.PACKED_PARAM_OBSERVED_URI); + List observedUris = convertJsonToObservedUris(uris); + return Trigger.contentUriTrigger(Collections.unmodifiableList(observedUris)); + + default: + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Unsupported trigger."); + } + return null; + } + } + + private void encodeTrigger(JobTrigger trigger, Bundle data) { + if (trigger == Trigger.NOW) { + data.putInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_TYPE, + BundleProtocol.TRIGGER_TYPE_IMMEDIATE); + } else if (trigger instanceof JobTrigger.ExecutionWindowTrigger) { + JobTrigger.ExecutionWindowTrigger t = (JobTrigger.ExecutionWindowTrigger) trigger; + + data.putInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_TYPE, + BundleProtocol.TRIGGER_TYPE_EXECUTION_WINDOW); + data.putInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_WINDOW_START, + t.getWindowStart()); + data.putInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_WINDOW_END, + t.getWindowEnd()); + } else if (trigger instanceof JobTrigger.ContentUriTrigger) { + data.putInt(prefix + BundleProtocol.PACKED_PARAM_TRIGGER_TYPE, + BundleProtocol.TRIGGER_TYPE_CONTENT_URI); + ContentUriTrigger uriTrigger = (ContentUriTrigger) trigger; + String jsonTrigger = convertObservedUrisToJsonString(uriTrigger.getUris()); + data.putString(prefix + BundleProtocol.PACKED_PARAM_OBSERVED_URI, jsonTrigger); + } else { + throw new IllegalArgumentException("Unsupported trigger."); + } + } + + private RetryStrategy decodeRetryStrategy(Bundle data) { + int policy = data.getInt(prefix + BundleProtocol.PACKED_PARAM_RETRY_STRATEGY_POLICY); + if (policy != RetryStrategy.RETRY_POLICY_EXPONENTIAL + && policy != RetryStrategy.RETRY_POLICY_LINEAR) { + + return RetryStrategy.DEFAULT_EXPONENTIAL; + } + + //noinspection WrongConstant + return new RetryStrategy( + policy, + data.getInt(prefix + BundleProtocol.PACKED_PARAM_RETRY_STRATEGY_INITIAL_BACKOFF_SECONDS), + data.getInt(prefix + BundleProtocol.PACKED_PARAM_RETRY_STRATEGY_MAXIMUM_BACKOFF_SECONDS)); + } + + private void encodeRetryStrategy(RetryStrategy retryStrategy, Bundle data) { + if (retryStrategy == null) { + retryStrategy = RetryStrategy.DEFAULT_EXPONENTIAL; + } + + data.putInt(prefix + BundleProtocol.PACKED_PARAM_RETRY_STRATEGY_POLICY, + retryStrategy.getPolicy()); + data.putInt(prefix + BundleProtocol.PACKED_PARAM_RETRY_STRATEGY_INITIAL_BACKOFF_SECONDS, + retryStrategy.getInitialBackoff()); + data.putInt(prefix + BundleProtocol.PACKED_PARAM_RETRY_STRATEGY_MAXIMUM_BACKOFF_SECONDS, + retryStrategy.getMaximumBackoff()); + } + + @NonNull + private String convertObservedUrisToJsonString(@NonNull List uris) { + JSONObject contentUris = new JSONObject(); + JSONArray jsonFlags = new JSONArray(); + JSONArray jsonUris = new JSONArray(); + for (ObservedUri uri : uris) { + jsonFlags.put(uri.getFlags()); + jsonUris.put(uri.getUri()); + } + try { + contentUris.put(JSON_URI_FLAGS, jsonFlags); + contentUris.put(JSON_URIS, jsonUris); + } catch (JSONException e) { + throw new RuntimeException(e); + } + return contentUris.toString(); + } + + @NonNull + private List convertJsonToObservedUris(@NonNull String contentUrisJson) { + List uris = new ArrayList<>(); + try { + JSONObject json = new JSONObject(contentUrisJson); + JSONArray jsonFlags = json.getJSONArray(JSON_URI_FLAGS); + JSONArray jsonUris = json.getJSONArray(JSON_URIS); + int length = jsonFlags.length(); + + for (int i = 0; i < length; i++) { + int flags = jsonFlags.getInt(i); + String uri = jsonUris.getString(i); + uris.add(new ObservedUri(Uri.parse(uri), flags)); + } + } catch (JSONException e) { + throw new RuntimeException(e); + } + return uris; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobInvocation.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobInvocation.java new file mode 100644 index 00000000..08263e84 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobInvocation.java @@ -0,0 +1,236 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import com.firebase.jobdispatcher.Constraint.JobConstraint; + +/** + * An internal non-Job implementation of JobParameters. Passed to JobService invocations. + */ +/* package */ final class JobInvocation implements JobParameters { + + @NonNull + private final String mTag; + + @NonNull + private final String mService; + + @NonNull + private final JobTrigger mTrigger; + + private final boolean mRecurring; + + private final int mLifetime; + + @NonNull + @JobConstraint + private final int[] mConstraints; + + @NonNull + private final Bundle mExtras; + + private final RetryStrategy mRetryStrategy; + + private final boolean mReplaceCurrent; + + private final TriggerReason mTriggerReason; + + private JobInvocation(Builder builder) { + mTag = builder.mTag; + mService = builder.mService; + mTrigger = builder.mTrigger; + mRetryStrategy = builder.mRetryStrategy; + mRecurring = builder.mRecurring; + mLifetime = builder.mLifetime; + mConstraints = builder.mConstraints; + mExtras = builder.mExtras; + mReplaceCurrent = builder.mReplaceCurrent; + mTriggerReason = builder.mTriggerReason; + } + + @NonNull + @Override + public String getService() { + return mService; + } + + @NonNull + @Override + public String getTag() { + return mTag; + } + + @NonNull + @Override + public JobTrigger getTrigger() { + return mTrigger; + } + + @Override + public int getLifetime() { + return mLifetime; + } + + @Override + public boolean isRecurring() { + return mRecurring; + } + + @NonNull + @Override + public int[] getConstraints() { + return mConstraints; + } + + @NonNull + @Override + public Bundle getExtras() { + return mExtras; + } + + @NonNull + @Override + public RetryStrategy getRetryStrategy() { + return mRetryStrategy; + } + + @Override + public boolean shouldReplaceCurrent() { + return mReplaceCurrent; + } + + @Override + public TriggerReason getTriggerReason() { + return mTriggerReason; + } + + static final class Builder { + + @NonNull + private String mTag; + + @NonNull + private String mService; + + @NonNull + private JobTrigger mTrigger; + + private boolean mRecurring; + + private int mLifetime; + + @NonNull + @JobConstraint + private int[] mConstraints; + + @NonNull + private final Bundle mExtras = new Bundle(); + + private RetryStrategy mRetryStrategy; + + private boolean mReplaceCurrent; + + private TriggerReason mTriggerReason; + + JobInvocation build() { + if (mTag == null || mService == null || mTrigger == null) { + throw new IllegalArgumentException("Required fields were not populated."); + } + return new JobInvocation(this); + } + + public Builder setTag(@NonNull String mTag) { + this.mTag = mTag; + return this; + } + + public Builder setService(@NonNull String mService) { + this.mService = mService; + return this; + } + + public Builder setTrigger(@NonNull JobTrigger mTrigger) { + this.mTrigger = mTrigger; + return this; + } + + public Builder setRecurring(boolean mRecurring) { + this.mRecurring = mRecurring; + return this; + } + + public Builder setLifetime(@Lifetime.LifetimeConstant int mLifetime) { + this.mLifetime = mLifetime; + return this; + } + + public Builder setConstraints(@JobConstraint @NonNull int[] mConstraints) { + this.mConstraints = mConstraints; + return this; + } + + public Builder addExtras(@NonNull Bundle bundle) { + if (bundle != null) { + mExtras.putAll(bundle); + } + return this; + } + + public Builder setRetryStrategy(RetryStrategy mRetryStrategy) { + this.mRetryStrategy = mRetryStrategy; + return this; + } + + public Builder setReplaceCurrent(boolean mReplaceCurrent) { + this.mReplaceCurrent = mReplaceCurrent; + return this; + } + + public Builder setTriggerReason(TriggerReason triggerReason) { + this.mTriggerReason = triggerReason; + return this; + } + } + + /** + * @return true if the tag and the service of provided {@link JobInvocation} have the same + * values. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || !getClass().equals(o.getClass())) { + return false; + } + + JobInvocation jobInvocation = (JobInvocation) o; + + return mTag.equals(jobInvocation.mTag) + && mService.equals(jobInvocation.mService); + } + + @Override + public int hashCode() { + int result = mTag.hashCode(); + result = 31 * result + mService.hashCode(); + return result; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobParameters.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobParameters.java new file mode 100644 index 00000000..24f0844e --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobParameters.java @@ -0,0 +1,86 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import com.firebase.jobdispatcher.Constraint.JobConstraint; + +/** + * JobParameters represents anything that can describe itself in terms of Job components. + */ +public interface JobParameters { + + /** + * Returns the name of the backing JobService class. + */ + @NonNull + String getService(); + + /** + * Returns a string identifier for the Job. Used when cancelling Jobs and displaying debug + * messages. + */ + @NonNull + String getTag(); + + /** + * The Job's Trigger, which decides when the Job is ready to run. + */ + @NonNull + JobTrigger getTrigger(); + + /** + * The Job's lifetime; how long it should persist for. + */ + @Lifetime.LifetimeConstant + int getLifetime(); + + /** + * Whether the Job should repeat. + */ + boolean isRecurring(); + + /** + * The runtime constraints applied to this Job. A Job is not run until the trigger is activated + * and all the runtime constraints are satisfied. + */ + @JobConstraint + int[] getConstraints(); + + /** + * The optional set of user-supplied extras associated with this Job. + */ + @Nullable + Bundle getExtras(); + + /** + * The RetryStrategy for the Job. Used to determine how to handle failures. + */ + @NonNull + RetryStrategy getRetryStrategy(); + + /** + * Whether the Job should replace a pre-existing Job with the same tag. + */ + boolean shouldReplaceCurrent(); + + /** @return A {@link TriggerReason} that - if non null - describes why the job was triggered. */ + @Nullable + TriggerReason getTriggerReason(); +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobService.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobService.java new file mode 100644 index 00000000..41076e76 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobService.java @@ -0,0 +1,259 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.app.Service; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Binder; +import android.os.IBinder; +import android.os.Message; +import android.support.annotation.IntDef; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.util.SimpleArrayMap; +import android.util.Log; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Locale; + +/** + * JobService is the fundamental unit of work used in the JobDispatcher. + *

+ * Users will need to override {@link #onStartJob(JobParameters)}, which is where any asynchronous + * execution should start. This method, like most lifecycle methods, runs on the main thread; you + * must offload execution to another thread (or {@link android.os.AsyncTask}, or + * {@link android.os.Handler}, or your favorite flavor of concurrency). + *

+ * Once any asynchronous work is complete {@link #jobFinished(JobParameters, boolean)} should be + * called to inform the backing driver of the result. + *

+ * Implementations should also override {@link #onStopJob(JobParameters)}, which will be called if + * the scheduling engine wishes to interrupt your work (most likely because the runtime constraints + * that are associated with the job in question are no longer met). + */ +public abstract class JobService extends Service { + /** + * Returned to indicate the job was executed successfully. If the job is not recurring (i.e. a + * one-off) it will be dequeued and forgotten. If it is recurring the trigger will be reset and + * the job will be requeued. + */ + public static final int RESULT_SUCCESS = 0; + + /** + * Returned to indicate the job encountered an error during execution and should be retried after + * a backoff period. + */ + public static final int RESULT_FAIL_RETRY = 1; + + /** + * Returned to indicate the job encountered an error during execution but should not be retried. + * If the job is not recurring (i.e. a one-off) it will be dequeued and forgotten. If it is + * recurring the trigger will be reset and the job will be requeued. + */ + public static final int RESULT_FAIL_NORETRY = 2; + + static final String TAG = "FJD.JobService"; + + @VisibleForTesting + static final String ACTION_EXECUTE = "com.firebase.jobdispatcher.ACTION_EXECUTE"; + + /** + * Correlates job tags (unique strings) with Messages, which are used to signal the completion + * of a job. + */ + private final SimpleArrayMap runningJobs = new SimpleArrayMap<>(1); + private LocalBinder binder = new LocalBinder(); + + /** + * The entry point to your Job. Implementations should offload work to another thread of + * execution as soon as possible because this runs on the main thread. If work was offloaded, + * call {@link JobService#jobFinished(JobParameters, boolean)} to notify the scheduling service + * that the work is completed. + *

+ * In order to reschedule use {@link JobService#jobFinished(JobParameters, boolean)}. + * + * @return {@code true} if there is more work remaining in the worker thread, {@code false} if the job was completed. + */ + @MainThread + public abstract boolean onStartJob(JobParameters job); + + /** + * Called when the scheduling engine has decided to interrupt the execution of a running job, + * most likely because the runtime constraints associated with the job are no longer satisfied. + * The job must stop execution. + * + * @return true if the job should be retried + * @see com.firebase.jobdispatcher.JobInvocation.Builder#setRetryStrategy(RetryStrategy) + * @see RetryStrategy + */ + @MainThread + public abstract boolean onStopJob(JobParameters job); + + @MainThread + void start(JobParameters job, Message msg) { + synchronized (runningJobs) { + if (runningJobs.containsKey(job.getTag())) { + Log.w(TAG, String + .format(Locale.US, "Job with tag = %s was already running.", job.getTag())); + return; + } + runningJobs.put(job.getTag(), new JobCallback(msg)); + + boolean moreWork = onStartJob(job); + if (!moreWork) { + JobCallback callback = runningJobs.remove(job.getTag()); + if (callback != null) { + callback.sendResult(RESULT_SUCCESS); + } + } + } + } + + @MainThread + void stop(JobInvocation job) { + synchronized (runningJobs) { + JobCallback jobCallback = runningJobs.remove(job.getTag()); + + if (jobCallback == null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Provided job has already been executed."); + } + return; + } + boolean shouldRetry = onStopJob(job); + jobCallback.sendResult(shouldRetry ? RESULT_FAIL_RETRY : RESULT_SUCCESS); + } + } + + /** + * Callback to inform the scheduling driver that you've finished executing. Can be called from + * any thread. When the system receives this message, it will release the wakelock being held. + * + * @param job + * @param needsReschedule + * whether the job should be rescheduled + * @see com.firebase.jobdispatcher.JobInvocation.Builder#setRetryStrategy(RetryStrategy) + */ + public final void jobFinished(@NonNull JobParameters job, boolean needsReschedule) { + if (job == null) { + Log.e(TAG, "jobFinished called with a null JobParameters"); + return; + } + + synchronized (runningJobs) { + JobCallback jobCallback = runningJobs.remove(job.getTag()); + + if (jobCallback != null) { + jobCallback.sendResult(needsReschedule ? RESULT_FAIL_RETRY : RESULT_SUCCESS); + } + } + } + + @Override + public final int onStartCommand(Intent intent, int flags, int startId) { + stopSelf(startId); + + return START_NOT_STICKY; + } + + @Nullable + @Override + public final IBinder onBind(Intent intent) { + return binder; + } + + @Override + public final boolean onUnbind(Intent intent) { + synchronized (runningJobs) { + for (int i = runningJobs.size() - 1; i >= 0; i--) { + JobCallback callback = runningJobs.get(runningJobs.keyAt(i)); + if (callback != null && callback.message != null) { + if (callback.message.obj instanceof JobParameters) { + callback.sendResult(onStopJob((JobParameters) callback.message.obj) + // returned true, would like to be rescheduled + ? RESULT_FAIL_RETRY + // returned false, but was interrupted so consider it a fail + : RESULT_FAIL_NORETRY); + } + } + } + } + + return super.onUnbind(intent); + } + + @Override + public final void onRebind(Intent intent) { + super.onRebind(intent); + } + + @Override + public final void onStart(Intent intent, int startId) { + } + + @Override + protected final void dump(FileDescriptor fd, PrintWriter writer, String[] args) { + super.dump(fd, writer, args); + } + + @Override + public final void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + } + + @Override + public final void onTaskRemoved(Intent rootIntent) { + super.onTaskRemoved(rootIntent); + } + + /** + * The result returned from a job execution. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({RESULT_SUCCESS, RESULT_FAIL_RETRY, RESULT_FAIL_NORETRY}) + public @interface JobResult { + } + + private final static class JobCallback { + public final Message message; + + private JobCallback(Message message) { + this.message = message; + } + + void sendResult(@JobResult int result) { + try { + if (message != null) { + message.arg1 = result; + message.sendToTarget(); + } + } catch (Exception ignored) {}//catch this freaking crash!!!! + } + } + + class LocalBinder extends Binder { + JobService getService() { + return JobService.this; + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobServiceConnection.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobServiceConnection.java new file mode 100644 index 00000000..c96e9f68 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobServiceConnection.java @@ -0,0 +1,82 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.ExecutionDelegator.TAG; + +import android.content.ComponentName; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.os.Message; +import android.support.annotation.VisibleForTesting; +import android.util.Log; + +/** + * ServiceConnection for job execution. + */ +@VisibleForTesting +class JobServiceConnection implements ServiceConnection { + + private final JobInvocation jobInvocation; + // Should be sent only once. Can't be reused. + private final Message jobFinishedMessage; + private boolean wasMessageUsed = false; + + //Guarded by "this". Can be updated from main and binder threads. + private JobService.LocalBinder binder; + + JobServiceConnection(JobInvocation jobInvocation, Message jobFinishedMessage) { + this.jobFinishedMessage = jobFinishedMessage; + this.jobInvocation = jobInvocation; + this.jobFinishedMessage.obj = this.jobInvocation; + } + + @Override + public synchronized void onServiceConnected(ComponentName name, IBinder service) { + if (!(service instanceof JobService.LocalBinder)) { + Log.w(TAG, "Unknown service connected"); + return; + } + if (wasMessageUsed) { + Log.w(TAG, "onServiceConnected Duplicate calls. Ignored."); + return; + } else { + wasMessageUsed = true; + } + + binder = (JobService.LocalBinder) service; + + JobService jobService = binder.getService(); + + jobService.start(jobInvocation, jobFinishedMessage); + } + + @Override + public synchronized void onServiceDisconnected(ComponentName name) { + binder = null; + } + + synchronized boolean isBound() { + return binder != null; + } + + synchronized void onStop() { + if (isBound()) { + binder.getService().stop(jobInvocation); + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobTrigger.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobTrigger.java new file mode 100644 index 00000000..b1c510c7 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobTrigger.java @@ -0,0 +1,70 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import java.util.List; + +/** + * Contains all supported triggers. + */ +public class JobTrigger { + + /** + * ImmediateTrigger is a Trigger that's immediately available. The Job will be run as soon as + * the runtime constraints are satisfied. + */ + public static final class ImmediateTrigger extends JobTrigger { + /* package */ ImmediateTrigger() {} + } + + /** + * ExecutionWindow represents a Job trigger that becomes eligible once + * the current elapsed time exceeds the scheduled time + the {@code windowStart} + * value. The scheduler backend is encouraged to use the windowEnd value as a + * signal that the job should be run, but this is not an enforced behavior. + */ + public static final class ExecutionWindowTrigger extends JobTrigger { + private final int mWindowStart; + private final int mWindowEnd; + + /* package */ ExecutionWindowTrigger(int windowStart, int windowEnd) { + this.mWindowStart = windowStart; + this.mWindowEnd = windowEnd; + } + + public int getWindowStart() { + return mWindowStart; + } + + public int getWindowEnd() { + return mWindowEnd; + } + } + + /** A trigger that will be triggered on content update for any of provided uris. */ + public static final class ContentUriTrigger extends JobTrigger { + private final List uris; + + /* package */ ContentUriTrigger(List uris) { + this.uris = uris; + } + + public List getUris() { + return uris; + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobValidator.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobValidator.java new file mode 100644 index 00000000..c97198f4 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/JobValidator.java @@ -0,0 +1,49 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.Nullable; +import java.util.List; + +/** + * A JobValidator is an object that knows how to validate Jobs and some of their composite + * components. + */ +public interface JobValidator { + /** + * Returns a List of error messages, or null if the JobParameters is + * valid. + */ + @Nullable + List validate(JobParameters job); + + /** + * Returns a List of error messages, or null if the Trigger is + * valid. + * @param trigger + */ + @Nullable + List validate(JobTrigger trigger); + + /** + * Returns a List of error messages, or null if the RetryStrategy + * is valid. + */ + @Nullable + List validate(RetryStrategy retryStrategy); + +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Lifetime.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Lifetime.java new file mode 100644 index 00000000..456bc221 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Lifetime.java @@ -0,0 +1,40 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Lifetime represents how long a Job should last. + */ +public final class Lifetime { + /** + * The Job should be preserved until the next boot. This is the default. + */ + public final static int UNTIL_NEXT_BOOT = 1; + + /** + * The Job should be preserved "forever." + */ + public final static int FOREVER = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({FOREVER, UNTIL_NEXT_BOOT}) + @interface LifetimeConstant {} +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ObservedUri.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ObservedUri.java new file mode 100644 index 00000000..b22c15e0 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ObservedUri.java @@ -0,0 +1,83 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.net.Uri; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Represents a single observed URI and any associated flags. */ +public final class ObservedUri { + + private final Uri uri; + + private final int flags; + + /** Flag enforcement. */ + @IntDef(flag = true, value = Flags.FLAG_NOTIFY_FOR_DESCENDANTS) + @Retention(RetentionPolicy.SOURCE) + public @interface Flags { + + /** + * Triggers if any descendants of the given URI change. Corresponds to the {@code + * notifyForDescendants} of {@link android.content.ContentResolver#registerContentObserver}. + */ + int FLAG_NOTIFY_FOR_DESCENDANTS = 1 << 0; + } + + /** + * Create a new ObservedUri. + * + * @param uri The URI to observe. + * @param flags Any {@link Flags} associated with the URI. + */ + public ObservedUri(@NonNull Uri uri, @Flags int flags) { + if (uri == null) { + throw new IllegalArgumentException("URI must not be null."); + } + this.uri = uri; + this.flags = flags; + } + + public Uri getUri() { + return uri; + } + + public int getFlags() { + return flags; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ObservedUri)) { + return false; + } + + ObservedUri otherUri = (ObservedUri) o; + return flags == otherUri.flags && uri.equals(otherUri.uri); + } + + @Override + public int hashCode() { + return uri.hashCode() ^ flags; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/RetryStrategy.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/RetryStrategy.java new file mode 100644 index 00000000..7d9b3190 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/RetryStrategy.java @@ -0,0 +1,106 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * RetryStrategy represents an approach to handling job execution failures. Jobs will have a + * time-based backoff enforced, based on the chosen policy (one of {@code RETRY_POLICY_EXPONENTIAL} + * or {@code RETRY_POLICY_LINEAR}. + */ +public final class RetryStrategy { + /** + * Increase the backoff time exponentially. + *

+ * Calculated using {@code initial_backoff * 2 ^ (num_failures - 1)}. + */ + public final static int RETRY_POLICY_EXPONENTIAL = 1; + + /** + * Increase the backoff time linearly. + *

+ * Calculated using {@code initial_backoff * num_failures}. + */ + public final static int RETRY_POLICY_LINEAR = 2; + + /** + * Expected schedule is: [30s, 60s, 120s, 240s, ..., 3600s] + */ + public final static RetryStrategy DEFAULT_EXPONENTIAL = + new RetryStrategy(RETRY_POLICY_EXPONENTIAL, 30, 3600); + + /** + * Expected schedule is: [30s, 60s, 90s, 120s, ..., 3600s] + */ + public final static RetryStrategy DEFAULT_LINEAR = + new RetryStrategy(RETRY_POLICY_LINEAR, 30, 3600); + + @RetryPolicy + private final int mPolicy; + private final int mInitialBackoff; + private final int mMaximumBackoff; + + /* package */ RetryStrategy(@RetryPolicy int policy, int initialBackoff, int maximumBackoff) { + mPolicy = policy; + mInitialBackoff = initialBackoff; + mMaximumBackoff = maximumBackoff; + } + + /** + * Returns the backoff policy in place. + */ + @RetryPolicy + public int getPolicy() { + return mPolicy; + } + + /** + * Returns the initial backoff (i.e. when # of failures == 1), in seconds. + */ + public int getInitialBackoff() { + return mInitialBackoff; + } + + /** + * Returns the maximum backoff duration in seconds. + */ + public int getMaximumBackoff() { + return mMaximumBackoff; + } + + @IntDef({RETRY_POLICY_LINEAR, RETRY_POLICY_EXPONENTIAL}) + @Retention(RetentionPolicy.SOURCE) + public @interface RetryPolicy { + } + + /* package */ final static class Builder { + private final ValidationEnforcer mValidator; + + Builder(ValidationEnforcer validator) { + mValidator = validator; + } + + public RetryStrategy build(@RetryPolicy int policy, int initialBackoff, int maxBackoff) { + RetryStrategy rs = new RetryStrategy(policy, initialBackoff, maxBackoff); + mValidator.ensureValid(rs); + return rs; + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/SimpleJobService.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/SimpleJobService.java new file mode 100644 index 00000000..8cfbe5f4 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/SimpleJobService.java @@ -0,0 +1,90 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.os.AsyncTask; +import android.support.annotation.CallSuper; +import android.support.v4.util.SimpleArrayMap; + +/** + * SimpleJobService provides a simple way of doing background work in a JobService. + * + * Users should override onRunJob and return one of the {@link JobResult} ints. + */ +public abstract class SimpleJobService extends JobService { + private final SimpleArrayMap runningJobs = + new SimpleArrayMap<>(); + + @CallSuper + @Override + public boolean onStartJob(JobParameters job) { + AsyncJobTask async = new AsyncJobTask(this, job); + + synchronized (runningJobs) { + runningJobs.put(job, async); + } + + async.execute(); + + return true; // more work to do + } + + @CallSuper + @Override + public boolean onStopJob(JobParameters job) { + synchronized (runningJobs) { + AsyncJobTask async = runningJobs.remove(job); + if (async != null) { + async.cancel(true); + return true; + } + } + + return false; + } + + private void onJobFinished(JobParameters jobParameters, boolean b) { + synchronized (runningJobs) { + runningJobs.remove(jobParameters); + } + + jobFinished(jobParameters, b); + } + + @JobResult + public abstract int onRunJob(JobParameters job); + + private static class AsyncJobTask extends AsyncTask { + private final SimpleJobService jobService; + private final JobParameters jobParameters; + + private AsyncJobTask(SimpleJobService jobService, JobParameters jobParameters) { + this.jobService = jobService; + this.jobParameters = jobParameters; + } + + @Override + protected Integer doInBackground(Void... params) { + return jobService.onRunJob(jobParameters); + } + + @Override + protected void onPostExecute(Integer integer) { + jobService.onJobFinished(jobParameters, integer == JobService.RESULT_FAIL_RETRY); + } + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Trigger.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Trigger.java new file mode 100644 index 00000000..3e01ae9c --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/Trigger.java @@ -0,0 +1,73 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.NonNull; +import java.util.List; + +/** + * Generally, a Trigger is an object that can answer the question, "is this job ready to run?" + *

+ * More specifically, a Trigger is an opaque, abstract class used to root the type hierarchy. + */ +public final class Trigger { + + /** + * Immediate is a Trigger that's immediately available. The Job will be run as soon as the + * runtime constraints are satisfied. + *

+ * It is invalid to schedule an Immediate with a recurring Job. + */ + public final static JobTrigger.ImmediateTrigger NOW = new JobTrigger.ImmediateTrigger(); + + /** + * Creates a new ExecutionWindow based on the provided time interval. + * + * @param windowStart The earliest time (in seconds) the job should be + * considered eligible to run. Calculated from when the + * job was scheduled (for new jobs) or last run (for + * recurring jobs). + * @param windowEnd The latest time (in seconds) the job should be run in + * an ideal world. Calculated in the same way as + * {@code windowStart}. + * @throws IllegalArgumentException if the provided parameters are too + * restrictive. + */ + public static JobTrigger.ExecutionWindowTrigger executionWindow(int windowStart, int windowEnd) { + if (windowStart < 0) { + throw new IllegalArgumentException("Window start can't be less than 0"); + } else if (windowEnd < windowStart) { + throw new IllegalArgumentException("Window end can't be less than window start"); + } + + return new JobTrigger.ExecutionWindowTrigger(windowStart, windowEnd); + } + + /** + * Creates a new ContentUriTrigger based on the provided list of {@link ObservedUri}. + * + * @param uris The list of URIs to observe. The trigger will be available if a piece of content, + * corresponding to any of provided URIs, is updated. + * @throws IllegalArgumentException if provided list of URIs is null or empty. + */ + public static JobTrigger.ContentUriTrigger contentUriTrigger(@NonNull List uris) { + if (uris == null || uris.isEmpty()) { + throw new IllegalArgumentException("Uris must not be null or empty."); + } + return new JobTrigger.ContentUriTrigger(uris); + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/TriggerReason.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/TriggerReason.java new file mode 100644 index 00000000..00fc053b --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/TriggerReason.java @@ -0,0 +1,33 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.net.Uri; +import java.util.List; + +/** The class contains a summary of the events which caused the job to be executed. */ +public class TriggerReason { + private final List mTriggeredContentUris; + + TriggerReason(List mTriggeredContentUris) { + this.mTriggeredContentUris = mTriggeredContentUris; + } + + public List getTriggeredContentUris() { + return mTriggeredContentUris; + } +} diff --git a/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ValidationEnforcer.java b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ValidationEnforcer.java new file mode 100644 index 00000000..d9cfbe02 --- /dev/null +++ b/jobdispatcher/src/main/java/com/firebase/jobdispatcher/ValidationEnforcer.java @@ -0,0 +1,132 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import java.util.List; + +/** + * Wraps a JobValidator and provides helpful validation utilities. + */ +public class ValidationEnforcer implements JobValidator { + private final JobValidator mValidator; + + public ValidationEnforcer(JobValidator validator) { + mValidator = validator; + } + + /** + * {@inheritDoc} + */ + @Nullable + @Override + public List validate(JobParameters job) { + return mValidator.validate(job); + } + + /** + * {@inheritDoc} + * @param trigger + */ + @Nullable + @Override + public List validate(JobTrigger trigger) { + return mValidator.validate(trigger); + } + + /** + * {@inheritDoc} + */ + @Nullable + @Override + public List validate(RetryStrategy retryStrategy) { + return mValidator.validate(retryStrategy); + } + + /** + * Indicates whether the provided JobParameters is valid. + */ + public final boolean isValid(JobParameters job) { + return validate(job) == null; + } + + /** + * Indicates whether the provided JobTrigger is valid. + */ + public final boolean isValid(JobTrigger trigger) { + return validate(trigger) == null; + } + + /** + * Indicates whether the provided RetryStrategy is valid. + */ + public final boolean isValid(RetryStrategy retryStrategy) { + return validate(retryStrategy) == null; + } + + /** + * Throws a RuntimeException if the provided JobParameters is invalid. + * + * @throws ValidationException + */ + public final void ensureValid(JobParameters job) { + ensureNoErrors(validate(job)); + } + + /** + * Throws a RuntimeException if the provided JobTrigger is invalid. + * + * @throws ValidationException + */ + public final void ensureValid(JobTrigger trigger) { + ensureNoErrors(validate(trigger)); + } + + /** + * Throws a RuntimeException if the provided RetryStrategy is + * invalid. + * + * @throws ValidationException + */ + public final void ensureValid(RetryStrategy retryStrategy) { + ensureNoErrors(validate(retryStrategy)); + } + + private void ensureNoErrors(List errors) { + if (errors != null) { + throw new ValidationException("JobParameters is invalid", errors); + } + } + + /** + * An Exception thrown when a validation error is encountered. + */ + public final static class ValidationException extends RuntimeException { + private final List mErrors; + + public ValidationException(String msg, @NonNull List errors) { + super(msg + ": " + TextUtils.join("\n - ", errors)); + mErrors = errors; + } + + public List getErrors() { + return mErrors; + } + } +} diff --git a/jobdispatcher/src/test/java/android/net/http/AndroidHttpClient.java b/jobdispatcher/src/test/java/android/net/http/AndroidHttpClient.java new file mode 100644 index 00000000..0720a56f --- /dev/null +++ b/jobdispatcher/src/test/java/android/net/http/AndroidHttpClient.java @@ -0,0 +1,10 @@ +package android.net.http; + +/** + * Robolectric requires this class be available in the classpath, otherwise {@link + * org.robolectric.Shadows#shadowOf(android.os.Looper)} fails. We don't use AndroidHttpClient, so + * include a stub to make Robolectric happy. + * + * @see https://github.com/robolectric/robolectric/issues/1862 + */ +public class AndroidHttpClient {} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ConstraintTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ConstraintTest.java new file mode 100644 index 00000000..808d7a3c --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ConstraintTest.java @@ -0,0 +1,66 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; + +import android.text.TextUtils; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class ConstraintTest { + + /** + * Just to get 100% coverage. + */ + @Test + public void testPrivateConstructor() throws Exception { + TestUtil.assertHasSinglePrivateConstructor(Constraint.class); + } + + @Test + public void testCompactAndUnCompact() { + for (List combo : TestUtil.getAllConstraintCombinations()) { + int[] input = TestUtil.toIntArray(combo); + Arrays.sort(input); + + int[] output = Constraint.uncompact(Constraint.compact(input)); + Arrays.sort(output); + + for (int i = 0; i < combo.size(); i++) { + assertEquals("Combination = " + TextUtils.join(", ", combo), + input[i], + output[i]); + } + + assertEquals("Expected length of two arrays to be the same", + input.length, + output.length); + } + } + + @Test + public void compactNull() { + assertEquals(0, Constraint.compact(null)); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ContentUriTriggerTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ContentUriTriggerTest.java new file mode 100644 index 00000000..5ea200af --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ContentUriTriggerTest.java @@ -0,0 +1,52 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static junit.framework.Assert.assertEquals; + +import android.provider.ContactsContract; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Test for {@link ContentUriTrigger}. */ +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 21) +public class ContentUriTriggerTest { + + @Test(expected = IllegalArgumentException.class) + public void constrains_null() throws Exception { + Trigger.contentUriTrigger(null); + } + + @Test(expected = IllegalArgumentException.class) + public void constrains_emptyList() throws Exception { + Trigger.contentUriTrigger(Collections.emptyList()); + } + + @Test + public void constrains_valid() throws Exception { + List uris = Arrays.asList(new ObservedUri(ContactsContract.AUTHORITY_URI, 0)); + ContentUriTrigger uriTrigger = Trigger.contentUriTrigger(uris); + assertEquals(uris, uriTrigger.getUris()); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/DefaultJobValidatorTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/DefaultJobValidatorTest.java new file mode 100644 index 00000000..2d526417 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/DefaultJobValidatorTest.java @@ -0,0 +1,119 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static java.util.Collections.singletonList; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.provider.ContactsContract; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; +import com.firebase.jobdispatcher.ObservedUri.Flags; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class DefaultJobValidatorTest { + + @Mock + private Context mMockContext; + + private DefaultJobValidator mValidator; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + mValidator = new DefaultJobValidator(mMockContext); + } + + @SuppressWarnings("WrongConstant") + @Test + public void testValidate_retryStrategy() throws Exception { + Map> testCases = new HashMap<>(); + testCases.put( + new RetryStrategy(0 /* bad policy */, 30, 3600), + singletonList("Unknown retry policy provided")); + testCases.put( + new RetryStrategy(RetryStrategy.RETRY_POLICY_LINEAR, 15, 3600), + singletonList("Initial backoff must be at least 30s")); + testCases.put( + new RetryStrategy(RetryStrategy.RETRY_POLICY_EXPONENTIAL, 15, 3600), + singletonList("Initial backoff must be at least 30s")); + testCases.put( + new RetryStrategy(RetryStrategy.RETRY_POLICY_LINEAR, 30, 60), + singletonList("Maximum backoff must be greater than 300s (5 minutes)")); + testCases.put( + new RetryStrategy(RetryStrategy.RETRY_POLICY_EXPONENTIAL, 30, 60), + singletonList("Maximum backoff must be greater than 300s (5 minutes)")); + testCases.put( + new RetryStrategy(RetryStrategy.RETRY_POLICY_LINEAR, 301, 300), + singletonList("Maximum backoff must be greater than or equal to initial backoff")); + testCases.put( + new RetryStrategy(RetryStrategy.RETRY_POLICY_EXPONENTIAL, 301, 300), + singletonList("Maximum backoff must be greater than or equal to initial backoff")); + + for (Entry> testCase : testCases.entrySet()) { + List validationErrors = mValidator.validate(testCase.getKey()); + assertNotNull("Expected validation errors, but got null", validationErrors); + + for (String expected : testCase.getValue()) { + assertTrue( + "Expected validation errors to contain \"" + expected + "\"", + validationErrors.contains(expected)); + } + } + } + + @Test + public void testValidate_trigger() throws Exception { + Map testCases = new HashMap<>(); + + testCases.put(Trigger.NOW, null); + testCases.put(Trigger.executionWindow(0, 100), null); + ContentUriTrigger contentUriTrigger = + Trigger.contentUriTrigger( + Arrays.asList( + new ObservedUri( + ContactsContract.AUTHORITY_URI, Flags.FLAG_NOTIFY_FOR_DESCENDANTS))); + testCases.put(contentUriTrigger, null); + + for (Entry testCase : testCases.entrySet()) { + List validationErrors = mValidator.validate(testCase.getKey()); + if (testCase.getValue() == null) { + assertNull("Expected no validation errors for trigger", validationErrors); + } else { + assertTrue( + "Expected validation errors to contain \"" + testCase.getValue() + "\"", + validationErrors.contains(testCase.getValue())); + } + } + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExecutionDelegatorTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExecutionDelegatorTest.java new file mode 100644 index 00000000..a523c465 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExecutionDelegatorTest.java @@ -0,0 +1,224 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.TestUtil.getContentUriTrigger; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.support.annotation.NonNull; +import com.firebase.jobdispatcher.JobService.JobResult; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@SuppressWarnings("WrongConstant") +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 21) +public class ExecutionDelegatorTest { + + private Context mMockContext; + private TestJobReceiver mReceiver; + private ExecutionDelegator mExecutionDelegator; + + @Before + public void setUp() { + mMockContext = spy(RuntimeEnvironment.application); + doReturn("com.example.foo").when(mMockContext).getPackageName(); + + mReceiver = new TestJobReceiver(); + mExecutionDelegator = new ExecutionDelegator(mMockContext, mReceiver); + } + + @Test + public void testExecuteJob_sendsBroadcastWithJobAndMessage() throws Exception { + for (JobInvocation input : TestUtil.getJobInvocationCombinations()) { + verifyExecuteJob(input); + } + } + + private void verifyExecuteJob(JobInvocation input) throws Exception { + reset(mMockContext); + mReceiver.lastResult = -1; + + mReceiver.setLatch(new CountDownLatch(1)); + + mExecutionDelegator.executeJob(input); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + final ArgumentCaptor connCaptor = + ArgumentCaptor.forClass(ServiceConnection.class); + verify(mMockContext).bindService(intentCaptor.capture(), connCaptor.capture(), anyInt()); + + final Intent result = intentCaptor.getValue(); + // verify the intent was sent to the right place + assertEquals(input.getService(), result.getComponent().getClassName()); + assertEquals(JobService.ACTION_EXECUTE, result.getAction()); + + final ServiceConnection connection = connCaptor.getValue(); + + ComponentName cname = mock(ComponentName.class); + JobService.LocalBinder mockLocalBinder = mock(JobService.LocalBinder.class); + final JobParameters[] out = new JobParameters[1]; + + JobService mockJobService = new JobService() { + @Override + public boolean onStartJob(JobParameters job) { + out[0] = job; + return false; + } + + @Override + public boolean onStopJob(JobParameters job) { + return false; + } + }; + + when(mockLocalBinder.getService()).thenReturn(mockJobService); + + connection.onServiceConnected(cname, mockLocalBinder); + + TestUtil.assertJobsEqual(input, out[0]); + + // make sure the countdownlatch was decremented + assertTrue(mReceiver.mLatch.await(1, TimeUnit.SECONDS)); + + // verify the lastResult was set correctly + assertEquals(JobService.RESULT_SUCCESS, mReceiver.lastResult); + } + + @Test + public void testExecuteJob_handlesNull() { + assertFalse("Expected calling triggerExecution on null to fail and return false", + mExecutionDelegator.executeJob(null)); + } + + @Test + public void testHandleMessage_doesntCrashOnBadJobData() { + JobInvocation j = new JobInvocation.Builder() + .setService(TestJobService.class.getName()) + .setTag("tag") + .setTrigger(Trigger.NOW) + .build(); + + mExecutionDelegator.executeJob(j); + + ArgumentCaptor intentCaptor = + ArgumentCaptor.forClass(Intent.class); + ArgumentCaptor connCaptor = + ArgumentCaptor.forClass(ServiceConnection.class); + + //noinspection WrongConstant + verify(mMockContext).bindService(intentCaptor.capture(), connCaptor.capture(), anyInt()); + + Intent executeReq = intentCaptor.getValue(); + assertEquals(JobService.ACTION_EXECUTE, executeReq.getAction()); + } + + @Test + public void onStop_mock() throws InterruptedException { + JobInvocation job = new JobInvocation.Builder() + .setTag("TAG") + .setTrigger(getContentUriTrigger()) + .setService(TestJobService.class.getName()) + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) + .build(); + + reset(mMockContext); + mReceiver.lastResult = -1; + + mExecutionDelegator.executeJob(job); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + final ArgumentCaptor connCaptor = + ArgumentCaptor.forClass(ServiceConnection.class); + verify(mMockContext).bindService(intentCaptor.capture(), connCaptor.capture(), anyInt()); + + final Intent result = intentCaptor.getValue(); + // verify the intent was sent to the right place + assertEquals(job.getService(), result.getComponent().getClassName()); + assertEquals(JobService.ACTION_EXECUTE, result.getAction()); + + final JobParameters[] out = new JobParameters[2]; + + JobService mockJobService = new JobService() { + @Override + public boolean onStartJob(JobParameters job) { + out[0] = job; + return true; + } + + @Override + public boolean onStopJob(JobParameters job) { + out[1] = job; + return false; + } + }; + + JobService.LocalBinder mockLocalBinder = mock(JobService.LocalBinder.class); + when(mockLocalBinder.getService()).thenReturn(mockJobService); + + ComponentName componentName = mock(ComponentName.class); + final ServiceConnection connection = connCaptor.getValue(); + connection.onServiceConnected(componentName, mockLocalBinder); + + mExecutionDelegator.stopJob(job); + + TestUtil.assertJobsEqual(job, out[0]); + TestUtil.assertJobsEqual(job, out[1]); + } + + private final static class TestJobReceiver implements ExecutionDelegator.JobFinishedCallback { + int lastResult; + + private CountDownLatch mLatch; + + @Override + public void onJobFinished(@NonNull JobInvocation js, @JobResult int result) { + lastResult = result; + + if (mLatch != null) { + mLatch.countDown(); + } + } + + /** + * Convenience method for tests. + */ + public void setLatch(CountDownLatch latch) { + mLatch = latch; + } + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExecutionWindowTriggerTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExecutionWindowTriggerTest.java new file mode 100644 index 00000000..6dfe8577 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExecutionWindowTriggerTest.java @@ -0,0 +1,76 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class ExecutionWindowTriggerTest { + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Test + public void testNewInstance_withValidWindow() throws Exception { + JobTrigger.ExecutionWindowTrigger trigger = Trigger.executionWindow(0, 60); + + assertEquals(0, trigger.getWindowStart()); + assertEquals(60, trigger.getWindowEnd()); + } + + @Test + public void testNewInstance_withNegativeStart() throws Exception { + expectedException.expect(IllegalArgumentException.class); + + Trigger.executionWindow(-10, 60); + } + + @Test + public void testNewInstance_withNegativeEnd() throws Exception { + expectedException.expect(IllegalArgumentException.class); + + Trigger.executionWindow(0, -1); + } + + @Test + public void testNewInstance_withReversedValues() throws Exception { + expectedException.expect(IllegalArgumentException.class); + + Trigger.executionWindow(60, 0); + } + + @Test + public void testNewInstance_withTooSmallWindow_now() throws Exception { + expectedException.expect(IllegalArgumentException.class); + + Trigger.executionWindow(60, 59); + } + + @Test + public void testNewInstance_withTooSmallWindow_inFuture() throws Exception { + expectedException.expect(IllegalArgumentException.class); + + Trigger.executionWindow(200, 100); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExtendedShadowParcel.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExtendedShadowParcel.java new file mode 100644 index 00000000..e5ecf104 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ExtendedShadowParcel.java @@ -0,0 +1,40 @@ +package com.firebase.jobdispatcher; + +import android.os.IBinder; +import android.os.Parcel; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.RealObject; +import org.robolectric.shadows.ShadowParcel; + +/** + * ShadowParcel doesn't correctly handle {@link Parcel#writeStrongBinder(IBinder)} or {@link + * Parcel#readStrongBinder()}, so we shim a simple implementation that uses an in-memory map to read + * and write Binder objects. + */ +@Implements(Parcel.class) +public class ExtendedShadowParcel extends ShadowParcel { + @RealObject private Parcel realObject; + + // Map each IBinder to an integer, and use the super's int-writing capability to fake Binder + // read/writes. + private final AtomicInteger nextBinderId = new AtomicInteger(1); + private final Map binderMap = + Collections.synchronizedMap(new HashMap()); + + @Implementation + public void writeStrongBinder(IBinder binder) { + int id = nextBinderId.getAndIncrement(); + binderMap.put(id, binder); + realObject.writeInt(id); + } + + @Implementation + public IBinder readStrongBinder() { + return binderMap.get(realObject.readInt()); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/FirebaseJobDispatcherTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/FirebaseJobDispatcherTest.java new file mode 100644 index 00000000..dd0b22ee --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/FirebaseJobDispatcherTest.java @@ -0,0 +1,205 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.firebase.jobdispatcher.FirebaseJobDispatcher.ScheduleFailedException; +import java.util.Arrays; +import java.util.List; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class FirebaseJobDispatcherTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Mock + private Driver mDriver; + + @Mock + private JobValidator mValidator; + + private FirebaseJobDispatcher mDispatcher; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + when(mDriver.getValidator()).thenReturn(mValidator); + + mDispatcher = new FirebaseJobDispatcher(mDriver); + setDriverAvailability(true); + } + + @Test + public void testSchedule_passThrough() throws Exception { + final int[] possibleResults = { + FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS, + FirebaseJobDispatcher.SCHEDULE_RESULT_NO_DRIVER_AVAILABLE, + FirebaseJobDispatcher.SCHEDULE_RESULT_BAD_SERVICE, + FirebaseJobDispatcher.SCHEDULE_RESULT_UNKNOWN_ERROR, + FirebaseJobDispatcher.SCHEDULE_RESULT_UNSUPPORTED_TRIGGER}; + + for (int result : possibleResults) { + when(mDriver.schedule(null)).thenReturn(result); + assertEquals(result, mDispatcher.schedule(null)); + } + + verify(mDriver, times(possibleResults.length)).schedule(null); + } + + @Test + public void testSchedule_unavailable() throws Exception { + setDriverAvailability(false); + assertEquals( + FirebaseJobDispatcher.SCHEDULE_RESULT_NO_DRIVER_AVAILABLE, + mDispatcher.schedule(null)); + verify(mDriver, never()).schedule(null); + } + + @Test + public void testCancelJob() throws Exception { + final String tag = "foo"; + + // simulate success + when(mDriver.cancel(tag)) + .thenReturn(FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS); + + assertEquals( + "Expected dispatcher to pass the result of Driver#cancel(String, Class) through", + FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS, + mDispatcher.cancel(tag)); + + // verify the driver was indeed called + verify(mDriver).cancel(tag); + } + + @Test + public void testCancelJob_unavailable() throws Exception { + setDriverAvailability(false); // driver is unavailable + + assertEquals( + FirebaseJobDispatcher.CANCEL_RESULT_NO_DRIVER_AVAILABLE, + mDispatcher.cancel("foo")); + + // verify the driver was never even consulted + verify(mDriver, never()).cancel("foo"); + } + + @Test + public void testCancelAllJobs() throws Exception { + when(mDriver.cancelAll()).thenReturn(FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS); + + assertEquals(FirebaseJobDispatcher.CANCEL_RESULT_SUCCESS, mDispatcher.cancelAll()); + verify(mDriver).cancelAll(); + } + + @Test + public void testCancelAllJobs_unavailable() throws Exception { + setDriverAvailability(false); // driver is unavailable + + assertEquals( + FirebaseJobDispatcher.CANCEL_RESULT_NO_DRIVER_AVAILABLE, + mDispatcher.cancelAll()); + + verify(mDriver, never()).cancelAll(); + } + + @Test + public void testMustSchedule_success() throws Exception { + when(mDriver.schedule(null)).thenReturn(FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS); + + /* assert no exception is thrown */ + mDispatcher.mustSchedule(null); + } + + @Test + public void testMustSchedule_unavailable() throws Exception { + setDriverAvailability(false); // driver is unavailable + expectedException.expect(FirebaseJobDispatcher.ScheduleFailedException.class); + + mDispatcher.mustSchedule(null); + } + + @Test + public void testMustSchedule_failure() throws Exception { + final int[] possibleErrors = { + FirebaseJobDispatcher.SCHEDULE_RESULT_NO_DRIVER_AVAILABLE, + FirebaseJobDispatcher.SCHEDULE_RESULT_BAD_SERVICE, + FirebaseJobDispatcher.SCHEDULE_RESULT_UNKNOWN_ERROR, + FirebaseJobDispatcher.SCHEDULE_RESULT_UNSUPPORTED_TRIGGER}; + + for (int scheduleError : possibleErrors) { + when(mDriver.schedule(null)).thenReturn(scheduleError); + + try { + mDispatcher.mustSchedule(null); + + fail("Expected mustSchedule() with error code " + scheduleError + " to fail"); + } catch (ScheduleFailedException expected) { /* expected */ } + } + + verify(mDriver, times(possibleErrors.length)).schedule(null); + } + + @Test + public void testNewRetryStrategyBuilder() { + // custom validator that only approves strategies where initialbackoff == 30s + when(mValidator.validate(any(RetryStrategy.class))).thenAnswer(new Answer>() { + @Override + public List answer(InvocationOnMock invocation) throws Throwable { + RetryStrategy rs = (RetryStrategy) invocation.getArguments()[0]; + // only succeed if initialBackoff == 30s + return rs.getInitialBackoff() == 30 ? null : Arrays.asList("foo", "bar"); + } + }); + + try { + mDispatcher.newRetryStrategy(RetryStrategy.RETRY_POLICY_EXPONENTIAL, 0, 30); + fail("Expected initial backoff != 30s to fail using custom validator"); + } catch (Exception unused) { /* unused */ } + + try { + mDispatcher.newRetryStrategy(RetryStrategy.RETRY_POLICY_EXPONENTIAL, 30, 30); + } catch (Exception unused) { + fail("Expected initial backoff == 30s not to fail using custom validator"); + } + } + + public void setDriverAvailability(boolean driverAvailability) { + when(mDriver.isAvailable()).thenReturn(driverAvailability); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayCallbackExtractorTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayCallbackExtractorTest.java new file mode 100644 index 00000000..f6591850 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayCallbackExtractorTest.java @@ -0,0 +1,153 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.os.Bundle; +import android.os.IBinder; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.Pair; +import com.firebase.jobdispatcher.TestUtil.InspectableBinder; +import com.firebase.jobdispatcher.TestUtil.TransactionArguments; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config( + constants = BuildConfig.class, + manifest = Config.NONE, + sdk = 23, + shadows = {ExtendedShadowParcel.class} +) +public final class GooglePlayCallbackExtractorTest { + @Mock + private IBinder mBinder; + + private GooglePlayCallbackExtractor mExtractor; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mExtractor = new GooglePlayCallbackExtractor(); + } + + @Test + public void testExtractCallback_nullBundle() { + assertNull(mExtractor.extractCallback(null)); + } + + @Test + public void testExtractCallback_nullParcelable() { + Bundle emptyBundle = new Bundle(); + assertNull(extractCallback(emptyBundle)); + } + + @Test + public void testExtractCallback_badParcelable() { + Bundle misconfiguredBundle = new Bundle(); + misconfiguredBundle.putParcelable("callback", new BadParcelable(1)); + + assertNull(extractCallback(misconfiguredBundle)); + } + + @Test + public void testExtractCallback_goodParcelable() { + InspectableBinder binder = new InspectableBinder(); + Bundle validBundle = new Bundle(); + validBundle.putParcelable("callback", binder.toPendingCallback()); + + Pair extraction = extractCallback(validBundle); + assertNotNull(extraction); + assertEquals("should have stripped the 'callback' entry from the extracted bundle", + 0, extraction.second.keySet().size()); + extraction.first.jobFinished(JobService.RESULT_SUCCESS); + + // Check our homemade Binder is doing the right things: + TransactionArguments args = binder.getArguments().get(0); + // Should have set the transaction code: + assertEquals("transaction code", IBinder.FIRST_CALL_TRANSACTION + 1, args.code); + + // strong mode bit + args.data.readInt(); + // interface token + assertEquals("com.google.android.gms.gcm.INetworkTaskCallback", args.data.readString()); + // result + assertEquals("result", JobService.RESULT_SUCCESS, args.data.readInt()); + } + + @Test + public void testExtractCallback_extraMapValues() { + Bundle validBundle = new Bundle(); + validBundle.putString("foo", "bar"); + validBundle.putInt("bar", 3); + validBundle.putParcelable("parcelable", new Bundle()); + validBundle.putParcelable("callback", new InspectableBinder().toPendingCallback()); + + Pair extraction = extractCallback(validBundle); + assertNotNull(extraction); + assertEquals("should have stripped the 'callback' entry from the extracted bundle", + 3, extraction.second.keySet().size()); + } + + private Pair extractCallback(Bundle bundle) { + return mExtractor.extractCallback(bundle); + } + + private static final class BadParcelable implements Parcelable { + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public BadParcelable createFromParcel(Parcel in) { + return new BadParcelable(in); + } + + @Override + public BadParcelable[] newArray(int size) { + return new BadParcelable[size]; + } + }; + private final int mNum; + + public BadParcelable(int i) { + mNum = i; + } + + private BadParcelable(Parcel in) { + mNum = in.readInt(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dst, int flags) { + dst.writeInt(mNum); + } + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayDriverTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayDriverTest.java new file mode 100644 index 00000000..822a8f1d --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayDriverTest.java @@ -0,0 +1,203 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ServiceInfo; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class GooglePlayDriverTest { + @Mock + public Context mMockContext; + + private TestJobDriver mDriver; + private FirebaseJobDispatcher mDispatcher; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mDriver = new TestJobDriver(new GooglePlayDriver(mMockContext)); + mDispatcher = new FirebaseJobDispatcher(mDriver); + + when(mMockContext.getPackageName()).thenReturn("foo.bar.whatever"); + } + + @Test + public void testSchedule_failsWhenPlayServicesIsUnavailable() throws Exception { + markBackendUnavailable(); + mockPackageManagerInfo(); + + Job job = null; + try { + job = mDispatcher.newJobBuilder() + .setService(TestJobService.class) + .setTag("foobar") + .setConstraints(Constraint.DEVICE_CHARGING) + .setTrigger(Trigger.executionWindow(0, 60)) + .build(); + } catch (ValidationEnforcer.ValidationException ve) { + fail(TextUtils.join("\n", ve.getErrors())); + } + + assertEquals("Expected schedule() request to fail when backend is unavailable", + FirebaseJobDispatcher.SCHEDULE_RESULT_NO_DRIVER_AVAILABLE, + mDispatcher.schedule(job)); + } + + @Test + public void testCancelJobs_backendUnavailable() throws Exception { + markBackendUnavailable(); + + assertEquals("Expected cancelAll() request to fail when backend is unavailable", + FirebaseJobDispatcher.CANCEL_RESULT_NO_DRIVER_AVAILABLE, + mDispatcher.cancelAll()); + } + + @Test + public void testSchedule_sendsAppropriateBroadcast() { + ArgumentCaptor pmQueryIntentCaptor = mockPackageManagerInfo(); + + Job job = mDispatcher.newJobBuilder() + .setConstraints(Constraint.DEVICE_CHARGING) + .setService(TestJobService.class) + .setTrigger(Trigger.executionWindow(0, 60)) + .setRecurring(false) + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) + .setTag("foobar") + .build(); + + Intent pmQueryIntent = pmQueryIntentCaptor.getValue(); + assertEquals(JobService.ACTION_EXECUTE, pmQueryIntent.getAction()); + assertEquals(TestJobService.class.getName(), pmQueryIntent.getComponent().getClassName()); + + assertEquals("Expected schedule() request to succeed", + FirebaseJobDispatcher.SCHEDULE_RESULT_SUCCESS, + mDispatcher.schedule(job)); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); + verify(mMockContext).sendBroadcast(captor.capture()); + + Intent broadcast = captor.getValue(); + + assertNotNull(broadcast); + assertEquals("com.google.android.gms.gcm.ACTION_SCHEDULE", broadcast.getAction()); + assertEquals("SCHEDULE_TASK", broadcast.getStringExtra("scheduler_action")); + assertEquals("com.google.android.gms", broadcast.getPackage()); + assertEquals(8, broadcast.getIntExtra("source", -1)); + assertEquals(1, broadcast.getIntExtra("source_version", -1)); + + final Parcelable parcelablePendingIntent = broadcast.getParcelableExtra("app"); + assertTrue("Expected 'app' value to be a PendingIntent", + parcelablePendingIntent instanceof PendingIntent); + } + + private ArgumentCaptor mockPackageManagerInfo() { + PackageManager packageManager = mock(PackageManager.class); + when(mMockContext.getPackageManager()).thenReturn(packageManager); + ArgumentCaptor intentArgCaptor = ArgumentCaptor.forClass(Intent.class); + + ResolveInfo info = new ResolveInfo(); + info.serviceInfo = new ServiceInfo(); + info.serviceInfo.enabled = true; + + //noinspection WrongConstant + when(packageManager.queryIntentServices(intentArgCaptor.capture(), eq(0))) + .thenReturn(Arrays.asList(info)); + + return intentArgCaptor; + } + + @Test + public void testCancel_sendsAppropriateBroadcast() { + mDispatcher.cancel("foobar"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); + verify(mMockContext).sendBroadcast(captor.capture()); + + Intent broadcast = captor.getValue(); + + assertNotNull(broadcast); + assertEquals("foobar", broadcast.getStringExtra("tag")); + } + + private void markBackendUnavailable() { + mDriver.available = false; + } + + public final static class TestJobDriver implements Driver { + public boolean available = true; + + private final Driver wrappedDriver; + + public TestJobDriver(Driver wrappedDriver) { + this.wrappedDriver = wrappedDriver; + } + + @Override + public int schedule(@NonNull Job job) { + return this.wrappedDriver.schedule(job); + } + + @Override + public int cancel(@NonNull String tag) { + return this.wrappedDriver.cancel(tag); + } + + @Override + public int cancelAll() { + return this.wrappedDriver.cancelAll(); + } + + @NonNull + @Override + public JobValidator getValidator() { + return this.wrappedDriver.getValidator(); + } + + @Override + public boolean isAvailable() { + return available; + } + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayJobWriterTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayJobWriterTest.java new file mode 100644 index 00000000..c56ab0d0 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayJobWriterTest.java @@ -0,0 +1,268 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract; +import com.firebase.jobdispatcher.Job.Builder; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; +import com.firebase.jobdispatcher.ObservedUri.Flags; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class GooglePlayJobWriterTest { + + private static final boolean[] ALL_BOOLEANS = {true, false}; + + private GooglePlayJobWriter mWriter; + + private static Builder initializeDefaultBuilder() { + return TestUtil.getBuilderWithNoopValidator() + .setConstraints(Constraint.DEVICE_CHARGING) + .setExtras(null) + .setLifetime(Lifetime.FOREVER) + .setRecurring(false) + .setReplaceCurrent(false) + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) + .setService(TestJobService.class) + .setTag("tag") + .setTrigger(Trigger.NOW); + } + + @Before + public void setUp() throws Exception { + mWriter = new GooglePlayJobWriter(); + } + + @Test + public void testWriteToBundle_tags() { + for (String tag : Arrays.asList("foo", "bar", "foobar", "this is a tag")) { + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder().setTag(tag).build(), + new Bundle()); + + assertEquals("tag", tag, b.getString("tag")); + } + } + + @Test + public void testWriteToBundle_updateCurrent() { + for (boolean replaceCurrent : ALL_BOOLEANS) { + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder().setReplaceCurrent(replaceCurrent).build(), + new Bundle()); + + assertEquals("update_current", replaceCurrent, b.getBoolean("update_current")); + } + } + + @Test + public void testWriteToBundle_persisted() { + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder().setLifetime(Lifetime.FOREVER).build(), + new Bundle()); + + assertTrue("persisted", b.getBoolean("persisted")); + + for (int lifetime : new int[]{Lifetime.UNTIL_NEXT_BOOT}) { + b = mWriter.writeToBundle( + initializeDefaultBuilder().setLifetime(lifetime).build(), + new Bundle()); + + assertFalse("persisted", b.getBoolean("persisted")); + } + } + + @Test + public void testWriteToBundle_service() { + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder().setService(TestJobService.class).build(), + new Bundle()); + + assertEquals("service", GooglePlayReceiver.class.getName(), b.getString("service")); + } + + @Test + public void testWriteToBundle_requiredNetwork() { + Map mapping = new HashMap<>(); + mapping.put(Constraint.ON_ANY_NETWORK, GooglePlayJobWriter.LEGACY_NETWORK_CONNECTED); + mapping.put(Constraint.ON_UNMETERED_NETWORK, GooglePlayJobWriter.LEGACY_NETWORK_UNMETERED); + mapping.put(0, GooglePlayJobWriter.LEGACY_NETWORK_ANY); + + for (Entry testCase : mapping.entrySet()) { + @SuppressWarnings("WrongConstant") + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder().setConstraints(testCase.getKey()).build(), + new Bundle()); + + assertEquals("requiredNetwork", (int) testCase.getValue(), b.getInt("requiredNetwork")); + } + } + + @Test + public void testWriteToBundle_unmeteredConstraintShouldTakePrecendence() { + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder() + .setConstraints(Constraint.ON_ANY_NETWORK, Constraint.ON_UNMETERED_NETWORK) + .build(), + new Bundle()); + + assertEquals("expected ON_UNMETERED_NETWORK to take precendence over ON_ANY_NETWORK", + GooglePlayJobWriter.LEGACY_NETWORK_UNMETERED, b.getInt("requiredNetwork")); + } + + @Test + public void testWriteToBundle_requiresCharging() { + assertTrue("requiresCharging", mWriter.writeToBundle( + initializeDefaultBuilder().setConstraints(Constraint.DEVICE_CHARGING).build(), + new Bundle()).getBoolean("requiresCharging")); + + for (Integer constraint : Arrays.asList( + Constraint.ON_ANY_NETWORK, + Constraint.ON_UNMETERED_NETWORK)) { + + assertFalse("requiresCharging", mWriter.writeToBundle( + initializeDefaultBuilder().setConstraints(constraint).build(), + new Bundle()).getBoolean("requiresCharging")); + } + } + + @Test + public void testWriteToBundle_requiresIdle() { + assertTrue("requiresIdle", mWriter.writeToBundle( + initializeDefaultBuilder().setConstraints(Constraint.DEVICE_IDLE).build(), + new Bundle()).getBoolean("requiresIdle")); + + for (Integer constraint : Arrays.asList( + Constraint.ON_ANY_NETWORK, + Constraint.ON_UNMETERED_NETWORK)) { + + assertFalse("requiresIdle", mWriter.writeToBundle( + initializeDefaultBuilder().setConstraints(constraint).build(), + new Bundle()).getBoolean("requiresIdle")); + } + } + + @Test + public void testWriteToBundle_retryPolicy() { + assertEquals("retry_policy", + GooglePlayJobWriter.LEGACY_RETRY_POLICY_EXPONENTIAL, + mWriter.writeToBundle( + initializeDefaultBuilder() + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) + .build(), + new Bundle()).getBundle("retryStrategy").getInt("retry_policy")); + + assertEquals("retry_policy", + GooglePlayJobWriter.LEGACY_RETRY_POLICY_LINEAR, + mWriter.writeToBundle( + initializeDefaultBuilder() + .setRetryStrategy(RetryStrategy.DEFAULT_LINEAR) + .build(), + new Bundle()).getBundle("retryStrategy").getInt("retry_policy")); + } + + @Test + public void testWriteToBundle_backoffSeconds() { + for (RetryStrategy retryStrategy : Arrays + .asList(RetryStrategy.DEFAULT_EXPONENTIAL, RetryStrategy.DEFAULT_LINEAR)) { + + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder().setRetryStrategy(retryStrategy).build(), + new Bundle()).getBundle("retryStrategy"); + + assertEquals("initial_backoff_seconds", + retryStrategy.getInitialBackoff(), + b.getInt("initial_backoff_seconds")); + + assertEquals("maximum_backoff_seconds", + retryStrategy.getMaximumBackoff(), + b.getInt("maximum_backoff_seconds")); + + } + } + + @Test + public void testWriteToBundle_triggers() { + // immediate + Bundle b = mWriter.writeToBundle( + initializeDefaultBuilder().setTrigger(Trigger.NOW).build(), + new Bundle()); + + assertEquals("window_start", 0, b.getLong("window_start")); + assertEquals("window_end", 30, b.getLong("window_end")); + + // execution window (oneoff) + JobTrigger.ExecutionWindowTrigger t = Trigger.executionWindow(631, 978); + b = mWriter.writeToBundle( + initializeDefaultBuilder().setTrigger(t).build(), + new Bundle()); + + assertEquals("window_start", t.getWindowStart(), b.getLong("window_start")); + assertEquals("window_end", t.getWindowEnd(), b.getLong("window_end")); + + // execution window (periodic) + b = mWriter.writeToBundle( + initializeDefaultBuilder().setRecurring(true).setTrigger(t).build(), + new Bundle()); + + assertEquals("period", t.getWindowEnd(), b.getLong("period")); + assertEquals("period_flex", t.getWindowEnd() - t.getWindowStart(), b.getLong("period_flex")); + } + + @Test + public void testWriteToBundle_contentUriTrigger() { + ObservedUri observedUri = new ObservedUri(ContactsContract.AUTHORITY_URI, + Flags.FLAG_NOTIFY_FOR_DESCENDANTS); + ContentUriTrigger contentUriTrigger = Trigger.contentUriTrigger(Arrays.asList(observedUri)); + Bundle bundle = mWriter.writeToBundle( + initializeDefaultBuilder().setTrigger(contentUriTrigger).build(), new Bundle()); + Uri[] uris = + (Uri[]) bundle.getParcelableArray(BundleProtocol.PACKED_PARAM_CONTENT_URI_ARRAY); + int[] flags = bundle.getIntArray(BundleProtocol.PACKED_PARAM_CONTENT_URI_FLAGS_ARRAY); + assertTrue("Array size", uris.length == flags.length && flags.length == 1); + assertEquals(BundleProtocol.PACKED_PARAM_CONTENT_URI_ARRAY, + ContactsContract.AUTHORITY_URI, uris[0]); + assertEquals(BundleProtocol.PACKED_PARAM_CONTENT_URI_FLAGS_ARRAY, + Flags.FLAG_NOTIFY_FOR_DESCENDANTS, flags[0]); + } + + @Test + public void testWriteToBundle_extras() { + Bundle extras = new Bundle(); + + Bundle result = mWriter.writeToBundle( + initializeDefaultBuilder().setExtras(extras).build(), + new Bundle()); + + assertEquals("extras", extras, result.getBundle("extras")); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayMessageHandlerTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayMessageHandlerTest.java new file mode 100644 index 00000000..31969a16 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayMessageHandlerTest.java @@ -0,0 +1,153 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.GooglePlayJobWriter.REQUEST_PARAM_TAG; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.AppOpsManager; +import android.content.Context; +import android.os.Bundle; +import android.os.Looper; +import android.os.Message; +import android.os.Messenger; +import com.firebase.jobdispatcher.JobInvocation.Builder; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Tests {@link GooglePlayMessageHandler}. + */ +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 21) +public class GooglePlayMessageHandlerTest { + + @Mock + Looper looper; + @Mock + GooglePlayReceiver receiverMock; + @Mock + Context context; + @Mock + AppOpsManager appOpsManager; + @Mock + Messenger messengerMock; + @Mock + ExecutionDelegator executionDelegatorMock; + + GooglePlayMessageHandler handler; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + handler = new GooglePlayMessageHandler(looper, receiverMock); + when(receiverMock.getExecutionDelegator()).thenReturn(executionDelegatorMock); + when(receiverMock.getApplicationContext()).thenReturn(context); + when(context.getSystemService(Context.APP_OPS_SERVICE)).thenReturn(appOpsManager); + } + + @Test + public void handleMessage_nullNoException() throws Exception { + handler.handleMessage(null); + } + + @Test + public void handleMessage_ignoreIfSenderIsNotGcm() throws Exception { + Message message = Message.obtain(); + message.what = GooglePlayMessageHandler.MSG_START_EXEC; + Bundle data = new Bundle(); + data.putString(REQUEST_PARAM_TAG, "TAG"); + message.setData(data); + message.replyTo = messengerMock; + doThrow(new SecurityException()).when(appOpsManager) + .checkPackage(message.sendingUid, GooglePlayDriver.BACKEND_PACKAGE); + handler.handleMessage(message); + verify(receiverMock, never()).prepareJob(any(GooglePlayMessengerCallback.class), eq(data)); + } + + @Test + public void handleMessage_startExecution_noData() throws Exception { + Message message = Message.obtain(); + message.what = GooglePlayMessageHandler.MSG_START_EXEC; + message.replyTo = messengerMock; + + handler.handleMessage(message); + verify(receiverMock, never()) + .prepareJob(any(GooglePlayMessengerCallback.class), any(Bundle.class)); + } + + @Test + public void handleMessage_startExecution() throws Exception { + Message message = Message.obtain(); + message.what = GooglePlayMessageHandler.MSG_START_EXEC; + Bundle data = new Bundle(); + data.putString(REQUEST_PARAM_TAG, "TAG"); + message.setData(data); + message.replyTo = messengerMock; + JobInvocation jobInvocation = new Builder() + .setTag("tag") + .setService(TestJobService.class.getName()) + .setTrigger(Trigger.NOW).build(); + when(receiverMock.prepareJob(any(GooglePlayMessengerCallback.class), eq(data))) + .thenReturn(jobInvocation); + + handler.handleMessage(message); + + verify(executionDelegatorMock).executeJob(jobInvocation); + } + + @Test + public void handleMessage_stopExecution() throws Exception { + Message message = Message.obtain(); + message.what = GooglePlayMessageHandler.MSG_STOP_EXEC; + JobCoder jobCoder = GooglePlayReceiver.getJobCoder(); + Bundle data = TestUtil.encodeContentUriJob(TestUtil.getContentUriTrigger(), jobCoder); + JobInvocation jobInvocation = jobCoder.decode(data).build(); + message.setData(data); + message.replyTo = messengerMock; + + handler.handleMessage(message); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(JobInvocation.class); + + verify(executionDelegatorMock).stopJob(captor.capture()); + + TestUtil.assertJobsEqual(jobInvocation, captor.getValue()); + } + + @Test + public void handleMessage_stopExecution_invalidNoCrash() throws Exception { + Message message = Message.obtain(); + message.what = GooglePlayMessageHandler.MSG_STOP_EXEC; + message.replyTo = messengerMock; + + handler.handleMessage(message); + + verify(executionDelegatorMock, never()).stopJob(any(JobInvocation.class)); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayMessengerCallbackTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayMessengerCallbackTest.java new file mode 100644 index 00000000..b71cf003 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayMessengerCallbackTest.java @@ -0,0 +1,63 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.GooglePlayJobWriter.REQUEST_PARAM_TAG; +import static org.junit.Assert.assertEquals; + +import android.os.Message; +import android.os.Messenger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Tests {@link GooglePlayMessengerCallback}. + */ +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 21) +public class GooglePlayMessengerCallbackTest { + + @Mock + Messenger messengerMock; + GooglePlayMessengerCallback callback; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + callback = new GooglePlayMessengerCallback(messengerMock, "tag"); + } + + @Test + public void jobFinished() throws Exception { + final ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + callback.jobFinished(JobService.RESULT_SUCCESS); + + Mockito.verify(messengerMock).send(messageCaptor.capture()); + Message message = messageCaptor.getValue(); + assertEquals(message.what, GooglePlayMessageHandler.MSG_RESULT); + assertEquals(message.arg1, JobService.RESULT_SUCCESS); + assertEquals(message.getData().getString(REQUEST_PARAM_TAG), "tag"); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayReceiverTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayReceiverTest.java new file mode 100644 index 00000000..47d7da57 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/GooglePlayReceiverTest.java @@ -0,0 +1,327 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.TestUtil.encodeContentUriJob; +import static com.firebase.jobdispatcher.TestUtil.encodeRecurringContentUriJob; +import static com.firebase.jobdispatcher.TestUtil.getContentUriTrigger; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +import android.app.Service; +import android.content.Intent; +import android.net.Uri; +import android.os.Binder; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Messenger; +import android.os.Parcel; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.MediaStore.Images.Media; +import android.support.annotation.NonNull; +import com.firebase.jobdispatcher.GooglePlayReceiverTest.ShadowMessenger; +import com.firebase.jobdispatcher.JobInvocation.Builder; +import com.firebase.jobdispatcher.TestUtil.InspectableBinder; +import com.google.android.gms.gcm.PendingCallback; +import java.util.ArrayList; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implements; + +@RunWith(RobolectricTestRunner.class) +@Config( + constants = BuildConfig.class, + manifest = Config.NONE, + sdk = 21, + shadows = {ShadowMessenger.class} +) +public class GooglePlayReceiverTest { + + /** + * The default ShadowMessenger implementation causes NPEs when using the + * {@link Messenger#Messenger(Handler)} constructor. We create our own empty Shadow so we can + * just use the standard Android implementation, which is totally fine. + * + * @see Robolectric issue + * + */ + @Implements(Messenger.class) + public static class ShadowMessenger {} + + GooglePlayReceiver receiver; + + JobCoder jobCoder = new JobCoder(BundleProtocol.PACKED_PARAM_BUNDLE_PREFIX, true); + + @Mock + Messenger messengerMock; + @Mock + IBinder binderMock; + @Mock + JobCallback callbackMock; + @Mock + ExecutionDelegator executionDelegatorMock; + @Mock + Driver driverMock; + @Captor + ArgumentCaptor jobArgumentCaptor; + + ArrayList triggeredUris = new ArrayList<>(); + + { + triggeredUris.add(ContactsContract.AUTHORITY_URI); + triggeredUris.add(Media.EXTERNAL_CONTENT_URI); + } + + Builder jobInvocationBuilder = new Builder() + .setTag("tag") + .setService(TestJobService.class.getName()) + .setTrigger(Trigger.NOW); + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + receiver = spy(new GooglePlayReceiver()); + when(receiver.getExecutionDelegator()).thenReturn(executionDelegatorMock); + receiver.driver = driverMock; + receiver.validationEnforcer = new ValidationEnforcer(new NoopJobValidator()); + } + + @Test + public void onJobFinished_unknownJobCallbackIsNotPresent_ignoreNoException() { + receiver.onJobFinished(jobInvocationBuilder.build(), JobService.RESULT_SUCCESS); + + verifyZeroInteractions(driverMock); + } + + @Test + public void onJobFinished_notRecurringContentJob_sendResult() { + jobInvocationBuilder.setTrigger( + Trigger.contentUriTrigger(Arrays.asList(new ObservedUri(Contacts.CONTENT_URI, 0)))); + + JobInvocation jobInvocation = receiver + .prepareJob(callbackMock, getBundleForContentJobExecution()); + + receiver.onJobFinished(jobInvocation, JobService.RESULT_SUCCESS); + verify(callbackMock).jobFinished(JobService.RESULT_SUCCESS); + verifyZeroInteractions(driverMock); + } + + @Test + public void onJobFinished_successRecurringContentJob_reschedule() { + JobInvocation jobInvocation = receiver + .prepareJob(callbackMock, getBundleForContentJobExecutionRecurring()); + + receiver.onJobFinished(jobInvocation, JobService.RESULT_SUCCESS); + + verify(driverMock).schedule(jobArgumentCaptor.capture()); + + // No need to callback when job finished. + // Reschedule request is treated as two events: completion of old job and scheduling of new + // job with the same parameters. + verifyZeroInteractions(callbackMock); + + Job rescheduledJob = jobArgumentCaptor.getValue(); + TestUtil.assertJobsEqual(jobInvocation, rescheduledJob); + } + + @Test + public void onJobFinished_failWithRetryRecurringContentJob_sendResult() { + JobInvocation jobInvocation = receiver + .prepareJob(callbackMock, getBundleForContentJobExecutionRecurring()); + + receiver.onJobFinished(jobInvocation, JobService.RESULT_FAIL_RETRY); + + // If a job finishes with RESULT_FAIL_RETRY we don't need to send a reschedule request. + // Rescheduling will erase previously triggered URIs. + verify(callbackMock).jobFinished(JobService.RESULT_FAIL_RETRY); + + verifyZeroInteractions(driverMock); + } + + @Test + public void prepareJob() { + Intent intent = new Intent(); + + Bundle encode = encodeContentUriJob(getContentUriTrigger(), jobCoder); + intent.putExtra(GooglePlayJobWriter.REQUEST_PARAM_EXTRAS, encode); + + Parcel container = Parcel.obtain(); + container.writeStrongBinder(new Binder()); + PendingCallback pcb = new PendingCallback(container); + intent.putExtra("callback", pcb); + + ArrayList uris = new ArrayList<>(); + uris.add(ContactsContract.AUTHORITY_URI); + uris.add(Media.EXTERNAL_CONTENT_URI); + intent.putParcelableArrayListExtra(BundleProtocol.PACKED_PARAM_TRIGGERED_URIS, uris); + + JobInvocation jobInvocation = receiver.prepareJob(intent); + assertEquals(jobInvocation.getTriggerReason().getTriggeredContentUris(), uris); + } + + @Test + public void prepareJob_messenger() { + JobInvocation jobInvocation = receiver.prepareJob(callbackMock, new Bundle()); + assertNull(jobInvocation); + verify(callbackMock).jobFinished(JobService.RESULT_FAIL_NORETRY); + } + + @Test + public void prepareJob_messenger_noExtras() { + Bundle bundle = getBundleForContentJobExecution(); + + JobInvocation jobInvocation = receiver.prepareJob(callbackMock, bundle); + assertEquals(jobInvocation.getTriggerReason().getTriggeredContentUris(), triggeredUris); + } + + @NonNull + private Bundle getBundleForContentJobExecution() { + Bundle bundle = new Bundle(); + + Bundle encode = encodeContentUriJob(getContentUriTrigger(), jobCoder); + bundle.putBundle(GooglePlayJobWriter.REQUEST_PARAM_EXTRAS, encode); + + bundle.putParcelableArrayList(BundleProtocol.PACKED_PARAM_TRIGGERED_URIS, triggeredUris); + return bundle; + } + + @NonNull + private Bundle getBundleForContentJobExecutionRecurring() { + Bundle bundle = new Bundle(); + + Bundle encode = encodeRecurringContentUriJob(getContentUriTrigger(), jobCoder); + bundle.putBundle(GooglePlayJobWriter.REQUEST_PARAM_EXTRAS, encode); + + bundle.putParcelableArrayList(BundleProtocol.PACKED_PARAM_TRIGGERED_URIS, triggeredUris); + return bundle; + } + + @Test + public void onBind() { + Intent intent = new Intent(GooglePlayReceiver.ACTION_EXECUTE); + IBinder binderA = receiver.onBind(intent); + IBinder binderB = receiver.onBind(intent); + + assertEquals(binderA, binderB); + } + + @Test + public void onBind_nullIntent() { + IBinder binder = receiver.onBind(null); + assertNull(binder); + } + + @Test + public void onBind_wrongAction() { + Intent intent = new Intent("test"); + IBinder binder = receiver.onBind(intent); + assertNull(binder); + } + + @Test + @Config(sdk = VERSION_CODES.KITKAT) + public void onBind_wrongBuild() { + Intent intent = new Intent(GooglePlayReceiver.ACTION_EXECUTE); + IBinder binder = receiver.onBind(intent); + assertNull(binder); + } + + @Test + public void onStartCommand_nullIntent() { + assertResultWasStartNotSticky(receiver.onStartCommand(null, 0, 101)); + verify(receiver).stopSelf(101); + } + + @Test + public void onStartCommand_initAction() { + Intent initIntent = new Intent("com.google.android.gms.gcm.SERVICE_ACTION_INITIALIZE"); + assertResultWasStartNotSticky(receiver.onStartCommand(initIntent, 0, 101)); + verify(receiver).stopSelf(101); + } + + @Test + public void onStartCommand_unknownAction() { + Intent unknownIntent = new Intent("com.example.foo.bar"); + assertResultWasStartNotSticky(receiver.onStartCommand(unknownIntent, 0, 101)); + assertResultWasStartNotSticky(receiver.onStartCommand(unknownIntent, 0, 102)); + assertResultWasStartNotSticky(receiver.onStartCommand(unknownIntent, 0, 103)); + + InOrder inOrder = inOrder(receiver); + inOrder.verify(receiver).stopSelf(101); + inOrder.verify(receiver).stopSelf(102); + inOrder.verify(receiver).stopSelf(103); + } + + @Test + public void onStartCommand_executeActionWithEmptyExtras() { + Intent execIntent = new Intent("com.google.android.gms.gcm.ACTION_TASK_READY"); + assertResultWasStartNotSticky(receiver.onStartCommand(execIntent, 0, 101)); + verify(receiver).stopSelf(101); + } + + @Test + public void onStartCommand_executeAction() { + JobInvocation job = new JobInvocation.Builder() + .setTag("tag") + .setService("com.example.foo.FooService") + .setTrigger(Trigger.NOW) + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) + .setLifetime(Lifetime.UNTIL_NEXT_BOOT) + .setConstraints(new int[]{Constraint.DEVICE_IDLE}) + .build(); + + Intent execIntent = new Intent("com.google.android.gms.gcm.ACTION_TASK_READY") + .putExtra("extras", new JobCoder(BundleProtocol.PACKED_PARAM_BUNDLE_PREFIX, true) + .encode(job, new Bundle())) + .putExtra("callback", new InspectableBinder().toPendingCallback()); + + when(executionDelegatorMock.executeJob(any(JobInvocation.class))).thenReturn(true); + + assertResultWasStartNotSticky(receiver.onStartCommand(execIntent, 0, 101)); + + verify(receiver, never()).stopSelf(anyInt()); + verify(executionDelegatorMock).executeJob(any(JobInvocation.class)); + + receiver.onJobFinished(job, JobService.RESULT_SUCCESS); + + verify(receiver).stopSelf(101); + } + + private void assertResultWasStartNotSticky(int result) { + assertEquals( + "Result for onStartCommand wasn't START_NOT_STICKY", Service.START_NOT_STICKY, result); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ImmediateTriggerTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ImmediateTriggerTest.java new file mode 100644 index 00000000..1cf57ee6 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ImmediateTriggerTest.java @@ -0,0 +1,34 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class ImmediateTriggerTest { + /** + * Code coverage. + */ + @Test + public void testPrivateConstructor() throws Exception { + TestUtil.assertHasSinglePrivateConstructor(JobTrigger.ImmediateTrigger.class); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobBuilderTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobBuilderTest.java new file mode 100644 index 00000000..ffb4026e --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobBuilderTest.java @@ -0,0 +1,65 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class JobBuilderTest { + private static final int[] ALL_LIFETIMES = {Lifetime.UNTIL_NEXT_BOOT, Lifetime.FOREVER}; + + private Job.Builder mBuilder; + + @Before + public void setUp() throws Exception { + mBuilder = TestUtil.getBuilderWithNoopValidator(); + } + + @Test + public void testAddConstraints() { + mBuilder.setConstraints() + .addConstraint(Constraint.DEVICE_CHARGING) + .addConstraint(Constraint.ON_UNMETERED_NETWORK); + + int[] expected = {Constraint.DEVICE_CHARGING, Constraint.ON_UNMETERED_NETWORK}; + + assertEquals(Constraint.compact(expected), Constraint.compact(mBuilder.getConstraints())); + } + + @Test + public void testSetLifetime() { + for (int lifetime : ALL_LIFETIMES) { + mBuilder.setLifetime(lifetime); + assertEquals(lifetime, mBuilder.getLifetime()); + } + } + + @Test + public void testSetShouldReplaceCurrent() { + for (boolean replace : new boolean[]{true, false}) { + mBuilder.setReplaceCurrent(replace); + assertEquals(replace, mBuilder.shouldReplaceCurrent()); + } + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobCoderTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobCoderTest.java new file mode 100644 index 00000000..bd3ebfb9 --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobCoderTest.java @@ -0,0 +1,158 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static com.firebase.jobdispatcher.TestUtil.encodeContentUriJob; +import static com.firebase.jobdispatcher.TestUtil.getContentUriTrigger; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.MediaStore.Images.Media; +import com.firebase.jobdispatcher.Job.Builder; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; +import java.util.ArrayList; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class JobCoderTest { + private final JobCoder mCoder = new JobCoder(PREFIX, true); + private static final String PREFIX = "prefix"; + private Builder mBuilder; + + private static Builder setValidBuilderDefaults(Builder mBuilder) { + return mBuilder + .setTag("tag") + .setTrigger(Trigger.NOW) + .setService(TestJobService.class) + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL); + } + + @Before + public void setUp() throws Exception { + mBuilder = TestUtil.getBuilderWithNoopValidator(); + } + + @Test + public void testCodingIsLossless() { + for (JobParameters input : TestUtil.getJobCombinations(mBuilder)) { + + JobParameters output = mCoder.decode(mCoder.encode(input, input.getExtras())).build(); + + TestUtil.assertJobsEqual(input, output); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testEncode_throwsOnNullBundle() { + mCoder.encode(mBuilder.build(), null); + } + + @Test(expected = IllegalArgumentException.class) + public void testDecode_throwsOnNullBundle() { + mCoder.decode(null); + } + + @Test + public void testDecode_failsWhenMissingFields() { + assertNull("Expected null tag to cause decoding to fail", + mCoder.decode(mCoder.encode( + setValidBuilderDefaults(mBuilder).setTag(null).build(), + new Bundle()))); + + assertNull("Expected null service to cause decoding to fail", + mCoder.decode(mCoder.encode( + setValidBuilderDefaults(mBuilder).setService(null).build(), + new Bundle()))); + } + + @Test(expected = IllegalArgumentException.class) + public void testDecode_failsUnsupportedTrigger() { + mCoder.decode(mCoder.encode(setValidBuilderDefaults(mBuilder).setTrigger(null).build(), + new Bundle())); + } + + @Test + public void testDecode_ignoresMissingRetryStrategy() { + assertNotNull("Expected null retry strategy to cause decode to use a default", + mCoder.decode(mCoder.encode( + setValidBuilderDefaults(mBuilder).setRetryStrategy(null).build(), + new Bundle()))); + } + + @Test + public void encode_contentUriTrigger() { + Bundle encode = TestUtil.encodeContentUriJob(TestUtil.getContentUriTrigger(), mCoder); + int triggerType = encode.getInt(PREFIX + BundleProtocol.PACKED_PARAM_TRIGGER_TYPE); + assertEquals("Trigger type", BundleProtocol.TRIGGER_TYPE_CONTENT_URI, triggerType); + + String json = encode.getString(PREFIX + BundleProtocol.PACKED_PARAM_OBSERVED_URI); + String expectedJson = "{\"uri_flags\":[1,0],\"uris\":[\"content:\\/\\/com.android.contacts" + + "\",\"content:\\/\\/media\\/external\\/images\\/media\"]}"; + assertEquals("Json trigger", expectedJson, json); + } + + @Test + public void decode_contentUriTrigger() { + ContentUriTrigger contentUriTrigger = TestUtil.getContentUriTrigger(); + Bundle bundle = TestUtil.encodeContentUriJob(contentUriTrigger, mCoder); + JobInvocation decode = mCoder.decode(bundle).build(); + ContentUriTrigger trigger = (ContentUriTrigger) decode.getTrigger(); + assertEquals(contentUriTrigger.getUris(), trigger.getUris()); + } + + @Test + public void decode_addBundleAsExtras() { + ContentUriTrigger contentUriTrigger = TestUtil.getContentUriTrigger(); + Bundle bundle = TestUtil.encodeContentUriJob(contentUriTrigger, mCoder); + bundle.putString("test_key", "test_value"); + JobInvocation decode = mCoder.decode(bundle).build(); + assertEquals("test_value", decode.getExtras().getString("test_key")); + } + + @Test + public void decodeIntentBundle() { + Bundle bundle = new Bundle(); + + ContentUriTrigger uriTrigger = getContentUriTrigger(); + Bundle encode = encodeContentUriJob(uriTrigger, mCoder); + bundle.putBundle(GooglePlayJobWriter.REQUEST_PARAM_EXTRAS, encode); + + ArrayList uris = new ArrayList<>(); + uris.add(ContactsContract.AUTHORITY_URI); + uris.add(Media.EXTERNAL_CONTENT_URI); + bundle.putParcelableArrayList(BundleProtocol.PACKED_PARAM_TRIGGERED_URIS, uris); + + JobInvocation jobInvocation = mCoder.decodeIntentBundle(bundle); + + assertEquals(uris, jobInvocation.getTriggerReason().getTriggeredContentUris()); + assertEquals("TAG", jobInvocation.getTag()); + assertEquals(uriTrigger.getUris(), ((ContentUriTrigger) jobInvocation.getTrigger()) + .getUris()); + assertEquals(TestJobService.class.getName(), jobInvocation.getService()); + assertEquals(RetryStrategy.DEFAULT_EXPONENTIAL.getPolicy(), + jobInvocation.getRetryStrategy().getPolicy()); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobInvocationTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobInvocationTest.java new file mode 100644 index 00000000..769dd19d --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobInvocationTest.java @@ -0,0 +1,83 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.os.Bundle; +import com.firebase.jobdispatcher.JobInvocation.Builder; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class JobInvocationTest { + private Builder builder; + + @Before + public void setUp() { + builder = new Builder() + .setTag("tag") + .setService(TestJobService.class.getName()) + .setTrigger(Trigger.NOW); + } + + @SuppressWarnings("ConstantConditions") + @Test + public void testShouldReplaceCurrent() throws Exception { + assertTrue("Expected shouldReplaceCurrent() to return value passed in constructor", + builder.setReplaceCurrent(true).build().shouldReplaceCurrent()); + assertFalse("Expected shouldReplaceCurrent() to return value passed in constructor", + builder.setReplaceCurrent(false).build().shouldReplaceCurrent()); + } + + @Test + public void extras() throws Exception { + assertNotNull(builder.build().getExtras()); + + Bundle bundle = new Bundle(); + bundle.putLong("test", 1L); + Bundle extras = builder.addExtras(bundle).build().getExtras(); + assertEquals(1, extras.size()); + assertEquals(1L, extras.getLong("test")); + } + + @Test + public void contract_hashCode_equals() { + JobInvocation jobInvocation = builder.build(); + assertEquals(jobInvocation, builder.build()); + assertEquals(jobInvocation.hashCode(), builder.build().hashCode()); + JobInvocation jobInvocationNew = builder.setTag("new").build(); + assertNotEquals(jobInvocation, jobInvocationNew); + assertNotEquals(jobInvocation.hashCode(), jobInvocationNew.hashCode()); + } + + @Test + public void contract_hashCode_equals_triggerShouldBeIgnored() { + JobInvocation jobInvocation = builder.build(); + JobInvocation periodic = builder.setTrigger(Trigger.executionWindow(0, 1)).build(); + assertEquals(jobInvocation, periodic); + assertEquals(jobInvocation.hashCode(), periodic.hashCode()); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobServiceConnectionTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobServiceConnectionTest.java new file mode 100644 index 00000000..8a8be89f --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobServiceConnectionTest.java @@ -0,0 +1,116 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.os.IBinder; +import android.os.Message; +import com.firebase.jobdispatcher.JobInvocation.Builder; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * Test for {@link JobServiceConnection}. + */ +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class JobServiceConnectionTest { + + JobInvocation job = new Builder() + .setTag("tag") + .setService(TestJobService.class.getName()) + .setTrigger(Trigger.NOW) + .build(); + + @Mock + Message messageMock; + @Mock + JobService.LocalBinder binderMock; + @Mock + JobService jobServiceMock; + + JobServiceConnection connection; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(binderMock.getService()).thenReturn(jobServiceMock); + connection = new JobServiceConnection(job, messageMock); + } + + @Test + public void fullConnectionCycle() { + assertFalse(connection.isBound()); + + connection.onServiceConnected(null, binderMock); + verify(jobServiceMock).start(job, messageMock); + assertTrue(connection.isBound()); + + connection.onStop(); + verify(jobServiceMock).stop(job); + assertTrue(connection.isBound()); + + connection.onServiceDisconnected(null); + assertFalse(connection.isBound()); + } + + @Test + public void onServiceConnected_shouldNotSendExecutionRequestTwice() { + assertFalse(connection.isBound()); + + connection.onServiceConnected(null, binderMock); + verify(jobServiceMock).start(job, messageMock); + assertTrue(connection.isBound()); + reset(jobServiceMock); + + connection.onServiceConnected(null, binderMock); + verify(jobServiceMock, never()).start(job, messageMock); // start should not be called again + + connection.onStop(); + verify(jobServiceMock).stop(job); + assertTrue(connection.isBound()); + + connection.onServiceDisconnected(null); + assertFalse(connection.isBound()); + } + + @Test + public void stopOnUnboundConnection() { + assertFalse(connection.isBound()); + connection.onStop(); + verify(jobServiceMock, never()).onStopJob(job); + } + + @Test + public void onServiceConnectedWrongBinder() { + IBinder binder = mock(IBinder.class); + connection.onServiceConnected(null, binder); + assertFalse(connection.isBound()); + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobServiceTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobServiceTest.java new file mode 100644 index 00000000..83c7134f --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/JobServiceTest.java @@ -0,0 +1,346 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.content.Intent; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Message; +import android.os.Parcel; +import com.firebase.jobdispatcher.JobInvocation.Builder; +import com.google.android.gms.gcm.PendingCallback; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class JobServiceTest { + private static CountDownLatch countDownLatch; + + @Before + public void setUp() throws Exception {} + + @After + public void tearDown() throws Exception { + countDownLatch = null; + } + + @Test + public void testOnStartCommand_handlesNullIntent() throws Exception { + JobService service = spy(new ExampleJobService()); + int startId = 7; + + try { + service.onStartCommand(null, 0, startId); + + verify(service).stopSelf(startId); + } catch (NullPointerException npe) { + fail("Unexpected NullPointerException after calling onStartCommand with a null Intent."); + } + } + + @Test + public void testOnStartCommand_handlesNullAction() throws Exception { + JobService service = spy(new ExampleJobService()); + int startId = 7; + + Intent nullActionIntent = new Intent(); + service.onStartCommand(nullActionIntent, 0, startId); + + verify(service).stopSelf(startId); + } + + @Test + public void testOnStartCommand_handlesEmptyAction() throws Exception { + JobService service = spy(new ExampleJobService()); + int startId = 7; + + Intent emptyActionIntent = new Intent(""); + service.onStartCommand(emptyActionIntent, 0, startId); + + verify(service).stopSelf(startId); + } + + @Test + public void testOnStartCommand_handlesUnknownAction() throws Exception { + JobService service = spy(new ExampleJobService()); + int startId = 7; + + Intent emptyActionIntent = new Intent("foo.bar.baz"); + service.onStartCommand(emptyActionIntent, 0, startId); + + verify(service).stopSelf(startId); + } + + @Test + public void testOnStartCommand_handlesStartJob_nullData() { + JobService service = spy(new ExampleJobService()); + int startId = 7; + + Intent executeJobIntent = new Intent(JobService.ACTION_EXECUTE); + service.onStartCommand(executeJobIntent, 0, startId); + + verify(service).stopSelf(startId); + } + + @Test + public void testOnStartCommand_handlesStartJob_noTag() { + JobService service = spy(new ExampleJobService()); + int startId = 7; + + Intent executeJobIntent = new Intent(JobService.ACTION_EXECUTE); + Parcel p = Parcel.obtain(); + p.writeStrongBinder(mock(IBinder.class)); + executeJobIntent.putExtra("callback", new PendingCallback(p)); + + service.onStartCommand(executeJobIntent, 0, startId); + + verify(service).stopSelf(startId); + + p.recycle(); + } + + @Test + public void testOnStartCommand_handlesStartJob_noCallback() { + JobService service = spy(new ExampleJobService()); + int startId = 7; + + Intent executeJobIntent = new Intent(JobService.ACTION_EXECUTE); + executeJobIntent.putExtra("tag", "foobar"); + + service.onStartCommand(executeJobIntent, 0, startId); + + verify(service).stopSelf(startId); + } + + @Test + public void testOnStartCommand_handlesStartJob_validRequest() throws InterruptedException { + JobService service = spy(new ExampleJobService()); + + HandlerThread ht = new HandlerThread("handler"); + ht.start(); + Handler h = new Handler(ht.getLooper()); + + Intent executeJobIntent = new Intent(JobService.ACTION_EXECUTE); + + Job jobSpec = TestUtil.getBuilderWithNoopValidator() + .setTag("tag") + .setService(ExampleJobService.class) + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) + .setTrigger(Trigger.NOW) + .setLifetime(Lifetime.FOREVER) + .build(); + + countDownLatch = new CountDownLatch(1); + + ((JobService.LocalBinder) service.onBind(executeJobIntent)) + .getService() + .start(jobSpec, h.obtainMessage(ExecutionDelegator.JOB_FINISHED, jobSpec)); + + assertTrue("Expected job to run to completion", countDownLatch.await(5, TimeUnit.SECONDS)); + } + + @Test + public void testOnStartCommand_handlesStartJob_doNotStartRunningJobAgain() { + StoppableJobService service = new StoppableJobService(false); + + Job jobSpec = TestUtil.getBuilderWithNoopValidator() + .setTag("tag") + .setService(StoppableJobService.class) + .setTrigger(Trigger.NOW) + .build(); + + ((JobService.LocalBinder) service.onBind(null)).getService().start(jobSpec, null); + ((JobService.LocalBinder) service.onBind(null)).getService().start(jobSpec, null); + + assertEquals(1, service.getNumberOfExecutionRequestsReceived()); + } + + @Test + public void stop_noCallback_finished() { + JobService service = spy(new StoppableJobService(false)); + JobInvocation job = new Builder() + .setTag("Tag") + .setTrigger(Trigger.NOW) + .setService(StoppableJobService.class.getName()) + .build(); + service.stop(job); + verify(service, never()).onStopJob(job); + } + + @Test + public void stop_withCallback_retry() { + JobService service = spy(new StoppableJobService(false)); + + JobInvocation job = new Builder() + .setTag("Tag") + .setTrigger(Trigger.NOW) + .setService(StoppableJobService.class.getName()) + .build(); + + Handler handlerMock = mock(Handler.class); + Message message = Message.obtain(handlerMock); + service.start(job, message); + + service.stop(job); + verify(service).onStopJob(job); + verify(handlerMock).sendMessage(message); + assertEquals(message.arg1, JobService.RESULT_SUCCESS); + } + + @Test + public void stop_withCallback_done() { + JobService service = spy(new StoppableJobService(true)); + + JobInvocation job = new Builder() + .setTag("Tag") + .setTrigger(Trigger.NOW) + .setService(StoppableJobService.class.getName()) + .build(); + + Handler handlerMock = mock(Handler.class); + Message message = Message.obtain(handlerMock); + service.start(job, message); + + service.stop(job); + verify(service).onStopJob(job); + verify(handlerMock).sendMessage(message); + assertEquals(message.arg1, JobService.RESULT_FAIL_RETRY); + } + + @Test + public void onStartJob_jobFinishedReschedule() { + // Verify that a retry request from within onStartJob will cause the retry result to be sent + // to the bouncer service's handler, regardless of what value is ultimately returned from + // onStartJob. + JobService reschedulingService = new JobService() { + @Override + public boolean onStartJob(JobParameters job) { + // Reschedules job. + jobFinished(job, true /* retry this job */); + return false; + } + + @Override + public boolean onStopJob(JobParameters job) { + return false; + } + }; + + Job jobSpec = TestUtil.getBuilderWithNoopValidator() + .setTag("tag") + .setService(reschedulingService.getClass()) + .setTrigger(Trigger.NOW) + .build(); + Handler mock = mock(Handler.class); + Message message = new Message(); + message.setTarget(mock); + reschedulingService.start(jobSpec, message); + + verify(mock).sendMessage(message); + assertEquals(message.arg1, JobService.RESULT_FAIL_RETRY); + } + + @Test + public void onStartJob_jobFinishedNotReschedule() { + // Verify that a termination request from within onStartJob will cause the result to be sent + // to the bouncer service's handler, regardless of what value is ultimately returned from + // onStartJob. + JobService reschedulingService = new JobService() { + @Override + public boolean onStartJob(JobParameters job) { + jobFinished(job, false /* don't retry this job */); + return false; + } + + @Override + public boolean onStopJob(JobParameters job) { + return false; + } + }; + + Job jobSpec = TestUtil.getBuilderWithNoopValidator() + .setTag("tag") + .setService(reschedulingService.getClass()) + .setTrigger(Trigger.NOW) + .build(); + Handler mock = mock(Handler.class); + Message message = new Message(); + message.setTarget(mock); + reschedulingService.start(jobSpec, message); + + verify(mock).sendMessage(message); + assertEquals(message.arg1, JobService.RESULT_SUCCESS); + } + + public static class ExampleJobService extends JobService { + @Override + public boolean onStartJob(JobParameters job) { + countDownLatch.countDown(); + return false; + } + + @Override + public boolean onStopJob(JobParameters job) { + return false; + } + } + + public static class StoppableJobService extends JobService { + + private final boolean shouldReschedule; + + public int getNumberOfExecutionRequestsReceived() { + return amountOfExecutionRequestReceived.get(); + } + + private final AtomicInteger amountOfExecutionRequestReceived = new AtomicInteger(); + + public StoppableJobService(boolean shouldReschedule) { + this.shouldReschedule = shouldReschedule; + } + + @Override + public boolean onStartJob(JobParameters job) { + amountOfExecutionRequestReceived.incrementAndGet(); + return true; + } + + @Override + public boolean onStopJob(JobParameters job) { + return shouldReschedule; + } + + + } +} diff --git a/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ValidationEnforcerTest.java b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ValidationEnforcerTest.java new file mode 100644 index 00000000..2e4e939a --- /dev/null +++ b/jobdispatcher/src/test/java/com/firebase/jobdispatcher/ValidationEnforcerTest.java @@ -0,0 +1,187 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 23) +public class ValidationEnforcerTest { + private static final List ERROR_LIST = Collections.singletonList("error: foo"); + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Mock + private JobValidator mValidator; + + @Mock + private JobParameters mMockJobParameters; + + @Mock + private JobTrigger mMockTrigger; + + private ValidationEnforcer mEnforcer; + private RetryStrategy mRetryStrategy = RetryStrategy.DEFAULT_EXPONENTIAL; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + mEnforcer = new ValidationEnforcer(mValidator); + } + + @Test + public void testValidate_retryStrategy() throws Exception { + mEnforcer.validate(mRetryStrategy); + verify(mValidator).validate(mRetryStrategy); + } + + @Test + public void testValidate_jobSpec() throws Exception { + mEnforcer.validate(mMockJobParameters); + verify(mValidator).validate(mMockJobParameters); + } + + @Test + public void testValidate_trigger() throws Exception { + mEnforcer.validate(mMockTrigger); + verify(mValidator).validate(mMockTrigger); + } + + @Test + public void testIsValid_retryStrategy_invalid() throws Exception { + when(mValidator.validate(mRetryStrategy)) + .thenReturn(Collections.singletonList("error: foo")); + + assertFalse("isValid", mEnforcer.isValid(mRetryStrategy)); + } + + @Test + public void testIsValid_retryStrategy_valid() throws Exception { + when(mValidator.validate(mRetryStrategy)).thenReturn(null); + + assertTrue("isValid", mEnforcer.isValid(mRetryStrategy)); + + } + + @Test + public void testIsValid_trigger_invalid() throws Exception { + when(mValidator.validate(mMockTrigger)) + .thenReturn(Collections.singletonList("error: foo")); + + assertFalse("isValid", mEnforcer.isValid(mMockTrigger)); + } + + @Test + public void testIsValid_trigger_valid() throws Exception { + when(mValidator.validate(mMockTrigger)).thenReturn(null); + + assertTrue("isValid", mEnforcer.isValid(mMockTrigger)); + } + + @Test + public void testIsValid_jobSpec_invalid() throws Exception { + when(mValidator.validate(mMockJobParameters)).thenReturn(ERROR_LIST); + + assertFalse("isValid", mEnforcer.isValid(mMockJobParameters)); + } + + @Test + public void testIsValid_jobSpec_valid() throws Exception { + when(mValidator.validate(mMockJobParameters)).thenReturn(null); + + assertTrue("isValid", mEnforcer.isValid(mMockJobParameters)); + } + + @Test + public void testEnsureValid_retryStrategy_valid() throws Exception { + when(mValidator.validate(mRetryStrategy)).thenReturn(null); + + mEnforcer.ensureValid(mRetryStrategy); + } + + @Test + public void testEnsureValid_trigger_valid() throws Exception { + when(mValidator.validate(mMockTrigger)).thenReturn(null); + + mEnforcer.ensureValid(mMockTrigger); + } + + @Test + public void testEnsureValid_jobSpec_valid() throws Exception { + when(mValidator.validate(mMockJobParameters)).thenReturn(null); + + mEnforcer.ensureValid(mMockJobParameters); + } + + @Test + public void testEnsureValid_retryStrategy_invalid() throws Exception { + expectedException.expect(ValidationEnforcer.ValidationException.class); + + when(mValidator.validate(mRetryStrategy)).thenReturn(ERROR_LIST); + mEnforcer.ensureValid(mRetryStrategy); + } + + @Test + public void testEnsureValid_trigger_invalid() throws Exception { + expectedException.expect(ValidationEnforcer.ValidationException.class); + + when(mValidator.validate(mMockTrigger)).thenReturn(ERROR_LIST); + mEnforcer.ensureValid(mMockTrigger); + } + + @Test + public void testEnsureValid_jobSpec_invalid() throws Exception { + expectedException.expect(ValidationEnforcer.ValidationException.class); + + when(mValidator.validate(mMockJobParameters)).thenReturn(ERROR_LIST); + mEnforcer.ensureValid(mMockJobParameters); + } + + @Test + public void testValidationMessages() throws Exception { + when(mValidator.validate(mMockJobParameters)).thenReturn(ERROR_LIST); + + try { + mEnforcer.ensureValid(mMockJobParameters); + + fail("Expected ensureValid to fail"); + } catch (ValidationEnforcer.ValidationException ve) { + assertEquals("Expected ValidationException to have 1 error message", + 1, + ve.getErrors().size()); + } + } +} diff --git a/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/NoopJobValidator.java b/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/NoopJobValidator.java new file mode 100644 index 00000000..079a6eed --- /dev/null +++ b/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/NoopJobValidator.java @@ -0,0 +1,44 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import android.support.annotation.Nullable; +import java.util.List; + +/** + * A very simple Validator that thinks that everything is ok. Used for testing. + */ +class NoopJobValidator implements JobValidator { + + @Nullable + @Override + public List validate(JobParameters job) { + return null; + } + + @Nullable + @Override + public List validate(JobTrigger trigger) { + return null; + } + + @Nullable + @Override + public List validate(RetryStrategy retryStrategy) { + return null; + } +} diff --git a/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/TestJobService.java b/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/TestJobService.java new file mode 100644 index 00000000..77b3604a --- /dev/null +++ b/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/TestJobService.java @@ -0,0 +1,71 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +/** A very simple JobService that can be configured for individual tests. */ +public class TestJobService extends JobService { + + public interface JobServiceProxy { + boolean onStartJob(JobParameters job); + + boolean onStopJob(JobParameters job); + } + + public static final JobServiceProxy NOOP_PROXY = + new JobServiceProxy() { + @Override + public boolean onStartJob(JobParameters job) { + return false; + } + + @Override + public boolean onStopJob(JobParameters job) { + return false; + } + }; + + private static final Object lock = new Object(); + + // GuardedBy("lock") + private static JobServiceProxy currentProxy = NOOP_PROXY; + + public static void setProxy(JobServiceProxy proxy) { + synchronized (lock) { + currentProxy = proxy; + } + } + + public static void reset() { + synchronized (lock) { + currentProxy = NOOP_PROXY; + } + } + + @Override + public boolean onStartJob(JobParameters job) { + synchronized (lock) { + return currentProxy.onStartJob(job); + } + } + + @Override + public boolean onStopJob(JobParameters job) { + synchronized (lock) { + return currentProxy.onStopJob(job); + } + } +} diff --git a/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/TestUtil.java b/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/TestUtil.java new file mode 100644 index 00000000..85f9fadc --- /dev/null +++ b/jobdispatcher/src/testLib/java/com/firebase/jobdispatcher/TestUtil.java @@ -0,0 +1,375 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.firebase.jobdispatcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.os.Binder; +import android.os.Bundle; +import android.os.Parcel; +import android.provider.ContactsContract; +import android.provider.MediaStore.Images.Media; +import android.support.annotation.NonNull; +import com.firebase.jobdispatcher.Job.Builder; +import com.firebase.jobdispatcher.JobTrigger.ContentUriTrigger; +import com.firebase.jobdispatcher.ObservedUri.Flags; +import com.google.android.gms.gcm.PendingCallback; +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +/** + * Provides common utilities helpful for testing. + */ +public class TestUtil { + + private static final String TAG = "TAG"; + private static final String[] TAG_COMBINATIONS = {"tag", "foobar", "fooooooo", "bz", "100"}; + + private static final int[] LIFETIME_COMBINATIONS = { + Lifetime.UNTIL_NEXT_BOOT, + Lifetime.FOREVER}; + + private static final JobTrigger[] TRIGGER_COMBINATIONS = { + Trigger.executionWindow(0, 30), + Trigger.executionWindow(300, 600), + Trigger.executionWindow(86400, 86400 * 2), + Trigger.NOW, + Trigger.contentUriTrigger( + Arrays.asList(new ObservedUri(ContactsContract.AUTHORITY_URI, 0))), + Trigger.contentUriTrigger(Arrays.asList(new ObservedUri(ContactsContract.AUTHORITY_URI, 0), + new ObservedUri(ContactsContract.AUTHORITY_URI, Flags.FLAG_NOTIFY_FOR_DESCENDANTS))) + }; + + @SuppressWarnings("unchecked") + private static final List> SERVICE_COMBINATIONS = + Arrays.asList(TestJobService.class); + + private static final RetryStrategy[] RETRY_STRATEGY_COMBINATIONS = { + RetryStrategy.DEFAULT_LINEAR, + new RetryStrategy(RetryStrategy.RETRY_POLICY_LINEAR, 60, 300), + RetryStrategy.DEFAULT_EXPONENTIAL, + new RetryStrategy(RetryStrategy.RETRY_POLICY_EXPONENTIAL, 300, 600), + }; + + public static void assertHasSinglePrivateConstructor(Class cls) throws Exception { + Constructor[] constructors = cls.getDeclaredConstructors(); + assertEquals("expected number of constructors to be == 1", 1, constructors.length); + + Constructor constructor = constructors[0]; + assertFalse("expected constructor to be inaccessible", constructor.isAccessible()); + + constructor.setAccessible(true); + constructor.newInstance(); + } + + static List> getAllConstraintCombinations() { + List> combos = new LinkedList<>(); + + combos.add(Collections.emptyList()); + for (Integer cur : Constraint.ALL_CONSTRAINTS) { + for (int l = combos.size() - 1; l >= 0; l--) { + List oldCombo = combos.get(l); + List newCombo = Arrays.asList(new Integer[oldCombo.size() + 1]); + + Collections.copy(newCombo, oldCombo); + newCombo.set(oldCombo.size(), cur); + combos.add(newCombo); + } + combos.add(Collections.singletonList(cur)); + } + + return combos; + } + + static int[] toIntArray(List list) { + int[] input = new int[list.size()]; + for (int i = 0; i < list.size(); i++) { + input[i] = list.get(i); + } + return input; + } + + static List getJobCombinations(Builder builder) { + return getCombination(new JobBuilder(builder)); + } + + static List getJobInvocationCombinations() { + return getCombination(new JobInvocationBuilder()); + } + + private static List getCombination( + JobParameterBuilder buildJobParam) { + + List result = new ArrayList<>(); + for (String tag : TAG_COMBINATIONS) { + for (List constraintList : getAllConstraintCombinations()) { + for (boolean recurring : new boolean[]{true, false}) { + for (boolean replaceCurrent : new boolean[]{true, false}) { + for (int lifetime : LIFETIME_COMBINATIONS) { + for (JobTrigger trigger : TRIGGER_COMBINATIONS) { + for (Class service : SERVICE_COMBINATIONS) { + for (Bundle extras : getBundleCombinations()) { + for (RetryStrategy rs : RETRY_STRATEGY_COMBINATIONS) { + result.add(buildJobParam.build( + tag, + replaceCurrent, + constraintList, + recurring, + lifetime, + trigger, + service, + extras, + rs)); + } + } + } + } + } + } + } + } + } + return result; + } + + private static Bundle[] getBundleCombinations() { + List bundles = new LinkedList<>(); + bundles.add(new Bundle()); + + Bundle b = new Bundle(); + b.putString("foo", "bar"); + b.putInt("bar", 1); + b.putLong("baz", 3L); + bundles.add(b); + + return bundles.toArray(new Bundle[bundles.size()]); + } + + static void assertJobsEqual(JobParameters input, JobParameters output) { + assertNotNull("input", input); + assertNotNull("output", output); + + assertEquals("isRecurring()", input.isRecurring(), output.isRecurring()); + assertEquals("shouldReplaceCurrent()", + input.shouldReplaceCurrent(), + output.shouldReplaceCurrent()); + assertEquals("getLifetime()", input.getLifetime(), output.getLifetime()); + assertEquals("getTag()", input.getTag(), output.getTag()); + assertEquals("getService()", input.getService(), output.getService()); + assertEquals("getConstraints()", + Constraint.compact(input.getConstraints()), + Constraint.compact(output.getConstraints())); + + assertTriggersEqual(input.getTrigger(), output.getTrigger()); + assertBundlesEqual(input.getExtras(), output.getExtras()); + assertRetryStrategiesEqual(input.getRetryStrategy(), output.getRetryStrategy()); + } + + static void assertRetryStrategiesEqual(RetryStrategy in, RetryStrategy out) { + String prefix = "getRetryStrategy()."; + + assertEquals(prefix + "getPolicy()", + in.getPolicy(), out.getPolicy()); + assertEquals(prefix + "getInitialBackoff()", + in.getInitialBackoff(), out.getInitialBackoff()); + assertEquals(prefix + "getMaximumBackoff()", + in.getMaximumBackoff(), out.getMaximumBackoff()); + } + + static void assertBundlesEqual(Bundle inExtras, Bundle outExtras) { + if (inExtras == null || outExtras == null) { + assertNull(inExtras); + assertNull(outExtras); + return; + } + + assertEquals("getExtras().size()", inExtras.size(), outExtras.size()); + final Set inKeys = inExtras.keySet(); + for (String key : inKeys) { + assertTrue("getExtras().containsKey(\"" + key + "\")", outExtras.containsKey(key)); + assertEquals("getExtras().get(\"" + key + "\")", inExtras.get(key), outExtras.get(key)); + } + } + + static void assertTriggersEqual(JobTrigger inTrigger, JobTrigger outTrigger) { + assertEquals("", inTrigger.getClass(), outTrigger.getClass()); + + if (inTrigger instanceof JobTrigger.ExecutionWindowTrigger) { + assertEquals("getTrigger().getWindowStart()", + ((JobTrigger.ExecutionWindowTrigger) inTrigger).getWindowStart(), + ((JobTrigger.ExecutionWindowTrigger) outTrigger).getWindowStart()); + assertEquals("getTrigger().getWindowEnd()", + ((JobTrigger.ExecutionWindowTrigger) inTrigger).getWindowEnd(), + ((JobTrigger.ExecutionWindowTrigger) outTrigger).getWindowEnd()); + } else if (inTrigger == Trigger.NOW) { + assertEquals(inTrigger, outTrigger); + } else if (inTrigger instanceof JobTrigger.ContentUriTrigger) { + assertEquals("Collection of URIs", + ((ContentUriTrigger) inTrigger).getUris(), + ((ContentUriTrigger) outTrigger).getUris()); + } else { + fail("Unknown Trigger class: " + inTrigger.getClass()); + } + } + + @NonNull + public static Builder getBuilderWithNoopValidator() { + return new Builder(new ValidationEnforcer(new NoopJobValidator())); + } + + @NonNull + static Bundle encodeContentUriJob(ContentUriTrigger trigger, JobCoder coder) { + Job job = getBuilderWithNoopValidator() + .setTag(TAG) + .setTrigger(trigger) + .setService(TestJobService.class) + .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) + .build(); + return coder.encode(job, new Bundle()); + } + + @NonNull + static Bundle encodeRecurringContentUriJob(ContentUriTrigger trigger, JobCoder coder) { + Job job = getBuilderWithNoopValidator() + .setTag(TAG) + .setTrigger(trigger) + .setService(TestJobService.class) + .setReplaceCurrent(true) + .setRecurring(true) + .build(); + return coder.encode(job, new Bundle()); + } + + static ContentUriTrigger getContentUriTrigger() { + ObservedUri contactUri = new ObservedUri( + ContactsContract.AUTHORITY_URI, Flags.FLAG_NOTIFY_FOR_DESCENDANTS); + ObservedUri imageUri = new ObservedUri(Media.EXTERNAL_CONTENT_URI, 0); + return Trigger.contentUriTrigger(Arrays.asList(contactUri, imageUri)); + } + + public static class TransactionArguments { + public final int code; + public final Parcel data; + public final int flags; + + public TransactionArguments(int code, Parcel data, int flags) { + this.code = code; + this.data = data; + this.flags = flags; + } + } + + public static class InspectableBinder extends Binder { + private final List transactionArguments = new LinkedList<>(); + + public InspectableBinder() {} + + @Override + protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) { + transactionArguments.add(new TransactionArguments(code, copyParcel(data), flags)); + return true; + } + + public PendingCallback toPendingCallback() { + Parcel container = Parcel.obtain(); + try { + container.writeStrongBinder(this); + container.setDataPosition(0); + return new PendingCallback(container); + } finally { + container.recycle(); + } + } + + private Parcel copyParcel(Parcel data) { + Parcel clone = Parcel.obtain(); + clone.appendFrom(data, 0, data.dataSize()); + clone.setDataPosition(0); + return clone; + } + + public List getArguments() { + return Collections.unmodifiableList(transactionArguments); + } + } + + private static class JobInvocationBuilder implements + JobParameterBuilder { + + @Override + public JobInvocation build(String tag, boolean replaceCurrent, List constraintList, + boolean recurring, int lifetime, JobTrigger trigger, Class service, + Bundle extras, RetryStrategy rs) { + //noinspection WrongConstant + return new JobInvocation.Builder() + .setTag(tag) + .setReplaceCurrent(replaceCurrent) + .setRecurring(recurring) + .setConstraints(toIntArray(constraintList)) + .setLifetime(lifetime) + .setTrigger(trigger) + .setService(service.getName()) + .addExtras(extras) + .setRetryStrategy(rs) + .build(); + } + } + + private static class JobBuilder implements JobParameterBuilder { + + private final Builder builder; + + public JobBuilder(Builder builder){ + this.builder = builder; + } + + @Override + public Job build(String tag, boolean replaceCurrent, List constraintList, + boolean recurring, int lifetime, JobTrigger trigger, Class service, + Bundle extras, RetryStrategy rs) { + //noinspection WrongConstant + return builder + .setTag(tag) + .setReplaceCurrent(replaceCurrent) + .setRecurring(recurring) + .setConstraints(toIntArray(constraintList)) + .setLifetime(lifetime) + .setTrigger(trigger) + .setService(service) + .setExtras(extras) + .setRetryStrategy(rs) + .build(); + } + } + + private interface JobParameterBuilder { + + T build(String tag, boolean replaceCurrent, List constraintList, boolean recurring, + int lifetime, JobTrigger trigger, Class service, Bundle extras, + RetryStrategy rs); + } +} diff --git a/jobdispatcher/src/testLib/java/com/google/android/gms/gcm/PendingCallback.java b/jobdispatcher/src/testLib/java/com/google/android/gms/gcm/PendingCallback.java new file mode 100644 index 00000000..a12317c8 --- /dev/null +++ b/jobdispatcher/src/testLib/java/com/google/android/gms/gcm/PendingCallback.java @@ -0,0 +1,61 @@ +// Copyright 2016 Google, Inc. +// +// 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 com.google.android.gms.gcm; + +import android.os.IBinder; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Keep; + +/** + * Parcelable class to wrap the binder we send to the client over IPC. Only included for the benefit + * of tests. + */ +@Keep +public final class PendingCallback implements Parcelable { + public static final Creator CREATOR = + new Creator() { + @Override + public PendingCallback createFromParcel(Parcel parcel) { + return new PendingCallback(parcel); + } + + @Override + public PendingCallback[] newArray(int i) { + return new PendingCallback[i]; + } + }; + private final IBinder mBinder; + + public PendingCallback(Parcel in) { + mBinder = in.readStrongBinder(); + } + + public IBinder getIBinder() { + return mBinder; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeStrongBinder(mBinder); + } +} diff --git a/settings.gradle b/settings.gradle index e7b4def4..57087241 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':app' +include ':app', ':jobdispatcher'