Browse Source

1.添加app升级功能

王鹏鹏 3 years ago
parent
commit
e7af7632c2
25 changed files with 3506 additions and 16 deletions
  1. 13 0
      home/src/main/AndroidManifest.xml
  2. 25 14
      home/src/main/java/com/yingyangfly/home/activity/HomeActivity.kt
  3. 2 1
      home/src/main/java/com/yingyangfly/home/activity/HomeViewModel.kt
  4. 13 0
      home/src/main/java/com/yingyangfly/home/entity/UpdateBean.kt
  5. 2 1
      home/src/main/java/com/yingyangfly/home/net/HomeApiService.kt
  6. 508 0
      home/src/main/java/com/yingyangfly/home/updater/AppUpdater.java
  7. 495 0
      home/src/main/java/com/yingyangfly/home/updater/UpdateConfig.java
  8. 26 0
      home/src/main/java/com/yingyangfly/home/updater/callback/AppUpdateCallback.java
  9. 51 0
      home/src/main/java/com/yingyangfly/home/updater/callback/UpdateCallback.java
  10. 32 0
      home/src/main/java/com/yingyangfly/home/updater/constant/Constants.java
  11. 237 0
      home/src/main/java/com/yingyangfly/home/updater/http/HttpManager.java
  12. 66 0
      home/src/main/java/com/yingyangfly/home/updater/http/IHttpManager.java
  13. 231 0
      home/src/main/java/com/yingyangfly/home/updater/http/OkHttpManager.java
  14. 83 0
      home/src/main/java/com/yingyangfly/home/updater/notify/INotification.java
  15. 41 0
      home/src/main/java/com/yingyangfly/home/updater/notify/NotificationImpl.java
  16. 10 0
      home/src/main/java/com/yingyangfly/home/updater/provider/AppUpdaterFileProvider.java
  17. 504 0
      home/src/main/java/com/yingyangfly/home/updater/service/DownloadService.java
  18. 359 0
      home/src/main/java/com/yingyangfly/home/updater/util/AppUtils.java
  19. 315 0
      home/src/main/java/com/yingyangfly/home/updater/util/LogUtils.java
  20. 276 0
      home/src/main/java/com/yingyangfly/home/updater/util/NotificationUtils.java
  21. 65 0
      home/src/main/java/com/yingyangfly/home/updater/util/PermissionUtils.java
  22. 120 0
      home/src/main/java/com/yingyangfly/home/updater/util/SSLSocketFactoryUtils.java
  23. 12 0
      home/src/main/manifest/AndroidManifest.xml
  24. 10 0
      home/src/main/res/values/strings.xml
  25. 10 0
      home/src/main/res/xml/app_updater_paths.xml

+ 13 - 0
home/src/main/AndroidManifest.xml

@@ -13,6 +13,19 @@
             android:launchMode="singleTask"
             android:screenOrientation="landscape"
             android:windowSoftInputMode="adjustResize|adjustPan" />
+
+        <service android:name="com.yingyangfly.home.updater.service.DownloadService"
+            android:exported="false"/>
+
+        <provider
+            android:name="com.yingyangfly.home.updater.provider.AppUpdaterFileProvider"
+            android:authorities="${applicationId}.AppUpdaterFileProvider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/app_updater_paths"/>
+        </provider>
     </application>
 
 </manifest>

+ 25 - 14
home/src/main/java/com/yingyangfly/home/activity/HomeActivity.kt

@@ -2,7 +2,6 @@ package com.yingyangfly.home.activity
 
 import android.annotation.SuppressLint
 import android.text.TextUtils
-import android.util.Log
 import androidx.recyclerview.widget.GridLayoutManager
 import com.alibaba.android.arouter.facade.annotation.Route
 import com.yingyang.home.databinding.ActivityHomeBinding
@@ -29,6 +28,7 @@ import com.yingyangfly.home.component.ShowProfessionalEvaluationViewComponent
 import com.yingyangfly.home.entity.HomePageMsgBean
 import com.yingyangfly.home.entity.Record
 import com.yingyangfly.home.net.XHomeServiceFactory
+import com.yingyangfly.home.updater.AppUpdater
 import io.reactivex.schedulers.Schedulers
 
 /**
@@ -165,16 +165,7 @@ class HomeActivity : BaseMVVMActivity<ActivityHomeBinding, HomeViewModel>() {
         getCountTrain()
         //获取未读消息数量
         getCountMyMsg()
-        //获取任务列表
-        getFindMyTask()
-        if (TextUtils.equals("0", User.getFirstLogin())) {
-            val taskDesn =
-                "欢迎" + User.getName() + "使用未来蓝豚康复平台!为您提供专业的认知康复支持和训练。帮助您提升认知能力,重建自信。小豚期待与您一同启程!"
-            showTaskDialog(taskDesn, "")
-        } else {
-            //获取任务状态弹窗
-            getSelectHomePageMsg()
-        }
+
     }
 
     /**
@@ -182,14 +173,34 @@ class HomeActivity : BaseMVVMActivity<ActivityHomeBinding, HomeViewModel>() {
      */
     private fun selectNewVersion() {
         val versionCode = AppUtil.getVersionCode(mContext)
-        Log.e("wpp", "versionCode-------------------->" + versionCode)
         viewModel.selectNewVersion(versionCode, fail = {
-            it.toast()
+            launchTask()
         }, success = {
-
+            if (it != null) {
+                val appUpdater = AppUpdater(this, it.versionUrl)
+                appUpdater.start()
+            } else {
+                launchTask()
+            }
         })
     }
 
+    /**
+     * 获取任务相关接口
+     */
+    private fun launchTask() {
+        //获取任务列表
+        getFindMyTask()
+        if (TextUtils.equals("0", User.getFirstLogin())) {
+            val taskDesn =
+                "欢迎" + User.getName() + "使用未来蓝豚康复平台!为您提供专业的认知康复支持和训练。帮助您提升认知能力,重建自信。小豚期待与您一同启程!"
+            showTaskDialog(taskDesn, "")
+        } else {
+            //获取任务状态弹窗
+            getSelectHomePageMsg()
+        }
+    }
+
     /**
      * 查询
      */

+ 2 - 1
home/src/main/java/com/yingyangfly/home/activity/HomeViewModel.kt

@@ -4,6 +4,7 @@ import com.yingyangfly.baselib.mvvm.BaseViewModel
 import com.yingyangfly.home.entity.CountTrainBean
 import com.yingyangfly.home.entity.MyTaskBean
 import com.yingyangfly.home.entity.Record
+import com.yingyangfly.home.entity.UpdateBean
 import com.yingyangfly.home.net.HOME_API
 import java.util.*
 
@@ -71,7 +72,7 @@ class HomeViewModel : BaseViewModel() {
     fun selectNewVersion(
         versionCode: Int,
         fail: ((msg: String) -> Unit)? = null,
-        success: ((success: Objects?) -> Unit)? = null,
+        success: ((success: UpdateBean?) -> Unit)? = null,
     ) = launchFlow(true) {
         HOME_API.selectNewVersion(versionCode)
     }.runUI(

+ 13 - 0
home/src/main/java/com/yingyangfly/home/entity/UpdateBean.kt

@@ -0,0 +1,13 @@
+package com.yingyangfly.home.entity
+
+/**
+ * app升级bean
+ */
+data class UpdateBean(
+    val createTime: String,
+    val id: String,
+    val updateTime: String,
+    val upgradeDesn: String,
+    val versionCode: Int,
+    val versionUrl: String
+)

+ 2 - 1
home/src/main/java/com/yingyangfly/home/net/HomeApiService.kt

@@ -4,6 +4,7 @@ import com.yingyangfly.baselib.net.BaseResp
 import com.yingyangfly.home.entity.CountTrainBean
 import com.yingyangfly.home.entity.MyTaskBean
 import com.yingyangfly.home.entity.Record
+import com.yingyangfly.home.entity.UpdateBean
 import retrofit2.http.POST
 import retrofit2.http.Query
 import java.util.*
@@ -37,6 +38,6 @@ interface HomeApiService {
      * 获取未读消息数量
      */
     @POST("app/selectNewVersion")
-    suspend fun selectNewVersion(@Query("versionCode") versionCode: Int): BaseResp<Objects>
+    suspend fun selectNewVersion(@Query("versionCode") versionCode: Int): BaseResp<UpdateBean>
 
 }

+ 508 - 0
home/src/main/java/com/yingyangfly/home/updater/AppUpdater.java

@@ -0,0 +1,508 @@
+package com.yingyangfly.home.updater;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.IBinder;
+import android.text.TextUtils;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.yingyangfly.home.updater.callback.UpdateCallback;
+import com.yingyangfly.home.updater.constant.Constants;
+import com.yingyangfly.home.updater.http.HttpManager;
+import com.yingyangfly.home.updater.http.IHttpManager;
+import com.yingyangfly.home.updater.http.OkHttpManager;
+import com.yingyangfly.home.updater.notify.INotification;
+import com.yingyangfly.home.updater.notify.NotificationImpl;
+import com.yingyangfly.home.updater.service.DownloadService;
+import com.yingyangfly.home.updater.util.LogUtils;
+import com.yingyangfly.home.updater.util.PermissionUtils;
+
+import java.util.Map;
+
+/**
+ * AppUpdater:一个专注于App更新,一键傻瓜式集成App版本升级的轻量开源库
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class AppUpdater {
+    /**
+     * {@link #mContext}不强制要求是{@link Activity},但能传{@link Activity}尽量传。AppUpdater本应该只专注于App更新,尽量不涉及动态权限相关的处理。如果mContext传的是{@link Activity},则默认会校验一次动态权限。
+     */
+    private Context mContext;
+    /**
+     * AppUpdater的配置信息
+     */
+    private UpdateConfig mConfig;
+    /**
+     * 更新回调
+     */
+    private UpdateCallback mCallback;
+    /**
+     * http管理接口,可自定义实现。如:使用okHttp
+     */
+    private IHttpManager mHttpManager;
+    /**
+     * 通知栏:如果当前的通知栏的布局不满足你的需求,可通过参考 {@link NotificationImpl} 去自定义实现一个 {@link INotification}
+     */
+    private INotification mNotification;
+
+    /**
+     * ServiceConnection
+     */
+    private ServiceConnection mServiceConnection;
+
+    /**
+     * 构造
+     *
+     * @param context {@link Context}
+     * @param config  {@link UpdateConfig}
+     */
+    public AppUpdater(@NonNull Context context, @NonNull UpdateConfig config) {
+        this.mContext = context;
+        this.mConfig = config;
+    }
+
+    /**
+     * 构造
+     *
+     * @param context {@link Context}
+     * @param url     下载地址
+     */
+    public AppUpdater(@NonNull Context context, @NonNull String url) {
+        this.mContext = context;
+        mConfig = new UpdateConfig();
+        mConfig.setUrl(url);
+    }
+
+    /**
+     * 设置下载更新进度回调
+     *
+     * @param callback 更新回调
+     * @return
+     */
+    public AppUpdater setUpdateCallback(@Nullable UpdateCallback callback) {
+        this.mCallback = callback;
+        return this;
+    }
+
+    /**
+     * 设置一个 {@link IHttpManager}
+     *
+     * @param httpManager AppUpdater内置提供{@link HttpManager} 和 {@link OkHttpManager}两种实现。
+     *                    如果不设置,将默认使用{@link HttpManager},你也可以使用{@link OkHttpManager}或自己去实现一个
+     *                    {@link IHttpManager}。
+     *                    当使用{@link OkHttpManager}时,必需依赖okhttp库
+     * @return
+     */
+    public AppUpdater setHttpManager(@Nullable IHttpManager httpManager) {
+        this.mHttpManager = httpManager;
+        return this;
+    }
+
+    /**
+     * 设置一个 {@link INotification}
+     *
+     * @param notification 如果当前的通知栏的布局不满足你的需求,可通过参考 {@link NotificationImpl} 去自定义实现一个 {@link INotification}
+     * @return
+     */
+    public AppUpdater setNotification(@Nullable INotification notification) {
+        this.mNotification = notification;
+        return this;
+    }
+
+    /**
+     * 开始下载
+     */
+    public void start() {
+        if (mConfig != null && !TextUtils.isEmpty(mConfig.getUrl())) {
+            // 如果mContext是Activity,并且配置了下载路径,则默认会校验一次动态权限。
+            if (mContext instanceof Activity && !TextUtils.isEmpty(mConfig.getPath())) {
+                PermissionUtils.verifyReadAndWritePermissions((Activity) mContext, Constants.RE_CODE_STORAGE_PERMISSION);
+            }
+
+            if (mConfig.isShowNotification() && !PermissionUtils.isNotificationEnabled(mContext)) {
+                LogUtils.w("Notification permission is not enabled.");
+            }
+            // 启动下载服务
+            startDownloadService();
+        } else {
+            throw new IllegalArgumentException("Url must not be empty.");
+        }
+    }
+
+    /**
+     * 启动下载服务
+     */
+    private void startDownloadService() {
+        Intent intent = new Intent(mContext, DownloadService.class);
+        if (mCallback != null || mHttpManager != null || mNotification != null) {
+            // 通过 bindService 的方式启动下载服务
+            mServiceConnection = new ServiceConnection() {
+                @Override
+                public void onServiceConnected(ComponentName name, IBinder service) {
+                    DownloadService.DownloadBinder binder = ((DownloadService.DownloadBinder) service);
+                    if (mNotification != null) {
+                        binder.start(mConfig, mHttpManager, mCallback, mNotification);
+                    } else {
+                        binder.start(mConfig, mHttpManager, mCallback);
+                    }
+                }
+
+                @Override
+                public void onServiceDisconnected(ComponentName name) {
+
+                }
+            };
+
+            mContext.getApplicationContext().bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
+        } else {
+            // 通过 startService 的方式启动下载服务
+            intent.putExtra(Constants.KEY_UPDATE_CONFIG, mConfig);
+            mContext.startService(intent);
+        }
+    }
+
+    /**
+     * 取消下载
+     */
+    public void stop() {
+        stopDownloadService();
+    }
+
+    /**
+     * 停止下载服务
+     */
+    private void stopDownloadService() {
+        Intent intent = new Intent(mContext, DownloadService.class);
+        intent.putExtra(Constants.KEY_STOP_DOWNLOAD_SERVICE, true);
+        mContext.startService(intent);
+    }
+
+    /**
+     * AppUpdater建造者
+     */
+    public static class Builder {
+        /**
+         * {@link Context}
+         */
+        private Context mContext;
+        /**
+         * AppUpdater的配置信息
+         */
+        private UpdateConfig mConfig;
+
+        /**
+         * 构造
+         *
+         * @deprecated 此方法已标记为废弃,后续可能会删除;请使用 {@link #Builder(Context)}
+         */
+        @Deprecated
+        public Builder() {
+            mConfig = new UpdateConfig();
+        }
+
+        /**
+         * 构造
+         *
+         * @param context {@link Context}
+         */
+        public Builder(@NonNull Context context) {
+            setContext(context);
+            mConfig = new UpdateConfig();
+        }
+
+        /**
+         * 设置上下文
+         *
+         * @param context {@link Context}
+         * @return
+         */
+        public Builder setContext(@NonNull Context context) {
+            mContext = context;
+            return this;
+        }
+
+        /**
+         * 设置APK下载地址
+         *
+         * @param url 下载地址
+         * @return
+         */
+        public Builder setUrl(@NonNull String url) {
+            mConfig.setUrl(url);
+            return this;
+        }
+
+        /**
+         * 设置保存的路径,(建议使用默认,不做设置)
+         *
+         * @param path 下载保存的文件路径
+         * @return
+         * @deprecated 因为适配Android Q的分区存储,所以此方法已弃用,不建议再使用
+         */
+        @Deprecated
+        public Builder setPath(String path) {
+            mConfig.setPath(path);
+            return this;
+        }
+
+        /**
+         * 设置保存的文件名
+         *
+         * @param filename 下载的保存的apk文件名(默认优先取url文件名)
+         * @return
+         */
+        public Builder setFilename(String filename) {
+            mConfig.setFilename(filename);
+            return this;
+        }
+
+        /**
+         * 设置是否显示通知栏
+         *
+         * @param isShowNotification 是否显示通知栏(默认true)
+         * @return
+         */
+        public Builder setShowNotification(boolean isShowNotification) {
+            mConfig.setShowNotification(isShowNotification);
+            return this;
+        }
+
+        /**
+         * 设置通知ID
+         *
+         * @param notificationId 通知ID
+         * @return
+         */
+        public Builder setNotificationId(int notificationId) {
+            mConfig.setNotificationId(notificationId);
+            return this;
+        }
+
+        /**
+         * 设置通知通道ID
+         *
+         * @param channelId 通知通道ID(默认兼容O)
+         * @return
+         */
+        public Builder setChannelId(String channelId) {
+            mConfig.setChannelId(channelId);
+            return this;
+        }
+
+        /**
+         * 设置通知通道名称
+         *
+         * @param channelName 通知通道名称(默认兼容O)
+         * @return
+         */
+        public Builder setChannelName(String channelName) {
+            mConfig.setChannelName(channelName);
+            return this;
+        }
+
+        /**
+         * 设置通知图标
+         *
+         * @param icon 通知栏图标(默认取App的icon)
+         * @return
+         */
+        public Builder setNotificationIcon(@DrawableRes int icon) {
+            mConfig.setNotificationIcon(icon);
+            return this;
+        }
+
+        /**
+         * 设置通知是否震动提示
+         *
+         * @param vibrate 是否震动提示,为true时使用通知默认震动,Android O(8.0)以上设置,只有初次创建channel时有效,后续修改属性无效,想要重新有效需修改channelId或卸载App重装。
+         * @return
+         */
+        public Builder setVibrate(boolean vibrate) {
+            mConfig.setVibrate(vibrate);
+            return this;
+        }
+
+        /**
+         * 设置通知是否铃声提示
+         *
+         * @param sound 是否铃声提示,为true时使用通知默认铃声,Android O(8.0)以上设置,只有初次创建channel时有效,后续修改属性无效,想要重新有效需修改channelId或卸载App重装。
+         * @return
+         */
+        public Builder setSound(boolean sound) {
+            mConfig.setSound(sound);
+            return this;
+        }
+
+
+        /**
+         * 设置下载完成后知否自动触发安装APK
+         *
+         * @param isInstallApk 下载完成后是否自动调用安装APK(默认true)
+         * @return
+         */
+        public Builder setInstallApk(boolean isInstallApk) {
+            mConfig.setInstallApk(isInstallApk);
+            return this;
+        }
+
+        /**
+         * 设置FileProvider的authority
+         *
+         * @param authority FileProvider的authority(默认兼容N,默认值{@link Context#getPackageName() + ".AppUpdaterFileProvider"})
+         * @return
+         */
+        public Builder setAuthority(String authority) {
+            mConfig.setAuthority(authority);
+            return this;
+        }
+
+        /**
+         * 设置下载时,通知栏是否显示下载百分比
+         *
+         * @param showPercentage 下载时通知栏是否显示百分比
+         * @return
+         */
+        public Builder setShowPercentage(boolean showPercentage) {
+            mConfig.setShowPercentage(showPercentage);
+            return this;
+        }
+
+        /**
+         * 设置下载失败时,是否支持点击通知栏重新下载。与之相关联的方法{@link #setReDownloads(int)}
+         *
+         * @param reDownload 下载失败时是否支持点击通知栏重新下载,默认true
+         * @return
+         */
+        public Builder setReDownload(boolean reDownload) {
+            mConfig.setReDownload(reDownload);
+            return this;
+        }
+
+        /**
+         * 设置下载失败时,最多重新下载次数。与之相关联的方法{@link #setReDownload(boolean)}
+         *
+         * @param reDownloads 下载失败时是否支持点击通知栏重新下载,默认最多重新下载3次
+         * @return
+         */
+        public Builder setReDownloads(int reDownloads) {
+            mConfig.setReDownloads(reDownloads);
+            return this;
+        }
+
+        /**
+         * 设置要下载APK的versionCode,用于优先取缓存时通过versionCode校验APK文件是否一致。
+         * 缓存校验目前支持两种方式,一种是通过versionCode校验,即{@link #setVersionCode(long)};一种是文件MD5校验,即{@link #setApkMD5(String)}。推荐使用MD5校验方式
+         * 如果两种方式都设置了,则只校验MD5
+         *
+         * @param versionCode 为null表示不处理,默认不存在则下载,存在则重新下载。不为null时,表示会优先校验本地是否存在已下载版本号为versionCode的APK。
+         *                    如果存在则不会重新下载(AppUpdater会自动校验packageName一致性),直接取本地APK,反之重新下载。
+         * @return
+         */
+        public Builder setVersionCode(long versionCode) {
+            mConfig.setVersionCode(versionCode);
+            return this;
+        }
+
+        /**
+         * 设置APK文件的MD5,用于优先取缓存时通过MD5校验文件APK是否一致。
+         * 缓存校验目前支持两种方式,一种是通过versionCode校验,即{@link #setVersionCode(long)};一种是文件MD5校验,即{@link #setApkMD5(String)}。推荐使用MD5校验方式
+         * 如果两种方式都设置了,则只校验MD5
+         *
+         * @param md5 为null表示不处理,如果设置了MD5,则缓存APK的MD5相同时,只下载一次,优先取本地缓存
+         * @return
+         */
+        public Builder setApkMD5(String md5) {
+            mConfig.setApkMD5(md5);
+            return this;
+        }
+
+        /**
+         * 请求头添加参数
+         *
+         * @param key
+         * @param value
+         * @return
+         */
+        public Builder addHeader(String key, String value) {
+            mConfig.addHeader(key, value);
+            return this;
+        }
+
+        /**
+         * 请求头添加参数
+         *
+         * @param headers
+         * @return
+         */
+        public Builder addHeader(Map<String, String> headers) {
+            mConfig.addHeader(headers);
+            return this;
+        }
+
+        /**
+         * 设置是否自动删除取消下载的文件
+         *
+         * @param deleteCancelFile 是否删除取消下载的文件(默认为true)
+         */
+        public Builder setDeleteCancelFile(boolean deleteCancelFile) {
+            mConfig.setDeleteCancelFile(deleteCancelFile);
+            return this;
+        }
+
+        /**
+         * 是否支持通过删除通知栏来取消下载(默认为:false)
+         *
+         * @param cancelDownload
+         * @return
+         * @deprecated 此方法已标记为废弃,后续可能会删除;请使用 {@link #setSupportCancelDownload(boolean)}
+         */
+        @Deprecated
+        public Builder setCancelDownload(boolean cancelDownload) {
+            return setSupportCancelDownload(cancelDownload);
+        }
+
+        /**
+         * 是否支持通过删除通知栏来取消下载(默认为:false)
+         *
+         * @param supportCancelDownload
+         * @return
+         */
+        public Builder setSupportCancelDownload(boolean supportCancelDownload) {
+            mConfig.setSupportCancelDownload(supportCancelDownload);
+            return this;
+        }
+
+        /**
+         * 构建 AppUpdater
+         *
+         * @return {@link AppUpdater}
+         */
+        public AppUpdater build() {
+            if (mContext == null) {
+                throw new NullPointerException("Context must not be null.");
+            }
+            return new AppUpdater(mContext, mConfig);
+        }
+
+        /**
+         * 构建 AppUpdater
+         *
+         * @param context
+         * @return {@link AppUpdater}
+         * @deprecated 此方法已标记为废弃,后续可能会删除;请使用 {@link #build()}
+         */
+        @Deprecated
+        public AppUpdater build(@NonNull Context context) {
+            return new AppUpdater(context, mConfig);
+        }
+
+    }
+
+}

+ 495 - 0
home/src/main/java/com/yingyangfly/home/updater/UpdateConfig.java

@@ -0,0 +1,495 @@
+package com.yingyangfly.home.updater;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.DrawableRes;
+
+import com.yingyangfly.home.updater.constant.Constants;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * AppUpdater的配置信息
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class UpdateConfig implements Parcelable {
+
+    /**
+     * APK下载的Url
+     */
+    private String mUrl;
+    /**
+     * 保存路径
+     */
+    private String mPath;
+    /**
+     * 保存文件名
+     */
+    private String mFilename;
+
+    /**
+     * 是否显示通知栏
+     */
+    private boolean isShowNotification = true;
+    /**
+     * 下载完成后是否自动弹出安装
+     */
+    private boolean isInstallApk = true;
+    /**
+     * 通知栏图标:默认取app图标
+     */
+    private int mNotificationIcon;
+
+    /**
+     * 通知栏ID
+     */
+    private int mNotificationId = Constants.DEFAULT_NOTIFICATION_ID;
+
+    /**
+     * 通知栏渠道ID
+     */
+    private String mChannelId;
+    /**
+     * 通知栏渠道名称
+     */
+    private String mChannelName;
+    /**
+     * 默认{@link Context#getPackageName() + ".AppUpdaterFileProvider"}
+     */
+    private String mAuthority;
+    /**
+     * 下载失败是否支持点击通知栏重新下载
+     */
+    private boolean isReDownload = true;
+    /**
+     * 下载失败后,最大重新下载次数
+     */
+    private int reDownloads = 3;
+    /**
+     * 是否显示百分比
+     */
+    private boolean isShowPercentage = true;
+
+    /**
+     * 是否震动提示,为true时使用通知默认震动
+     */
+    private boolean isVibrate;
+
+    /**
+     * 是否铃声提示,为true时使用通知默认铃声
+     */
+    private boolean isSound;
+
+    /**
+     * 要下载的APK的versionCode
+     */
+    private long versionCode = Constants.NONE;
+
+    /**
+     * 请求头参数
+     */
+    private Map<String, String> mRequestProperty;
+
+    /**
+     * 是否删除取消下载的文件
+     */
+    private boolean isDeleteCancelFile = true;
+
+    /**
+     * 是否支持通过删除通知栏来取消下载
+     */
+    private boolean isSupportCancelDownload = false;
+
+    /**
+     * APK文件的MD5
+     */
+    private String apkMD5;
+
+    public UpdateConfig() {
+
+    }
+
+    public String getUrl() {
+        return mUrl;
+    }
+
+    /**
+     * 设置APK下载地址
+     *
+     * @param url 下载地址
+     */
+    public void setUrl(String url) {
+        this.mUrl = url;
+    }
+
+    public String getPath() {
+        return mPath;
+    }
+
+    /**
+     * 设置保存的路径,(建议使用默认,不做设置)
+     *
+     * @param path 下载保存的文件路径
+     * @return
+     * @deprecated 因为适配Android Q的分区存储,所以此方法已弃用,不建议再使用
+     */
+    @Deprecated
+    public void setPath(String path) {
+        this.mPath = path;
+    }
+
+    public String getFilename() {
+        return mFilename;
+    }
+
+    /**
+     * 设置保存的文件名
+     *
+     * @param filename 下载的保存的apk文件名 (默认优先取url文件名)
+     */
+    public void setFilename(String filename) {
+        this.mFilename = filename;
+    }
+
+    public boolean isShowNotification() {
+        return isShowNotification;
+    }
+
+    /**
+     * 设置是否显示通知栏
+     *
+     * @param isShowNotification 是否显示通知栏 (默认true)
+     */
+    public void setShowNotification(boolean isShowNotification) {
+        this.isShowNotification = isShowNotification;
+    }
+
+    public String getChannelId() {
+        return mChannelId;
+    }
+
+    /**
+     * 设置通知通道ID
+     *
+     * @param channelId 通知通道ID(默认兼容O)
+     */
+    public void setChannelId(String channelId) {
+        this.mChannelId = channelId;
+    }
+
+    public String getChannelName() {
+        return mChannelName;
+    }
+
+    /**
+     * 设置通知通道名称
+     *
+     * @param channelName 通知通道名称(默认兼容O)
+     */
+    public void setChannelName(String channelName) {
+        this.mChannelName = channelName;
+    }
+
+    /**
+     * 设置通知ID
+     *
+     * @param notificationId 通知ID
+     */
+    public void setNotificationId(int notificationId) {
+        this.mNotificationId = notificationId;
+    }
+
+    public int getNotificationId() {
+        return this.mNotificationId;
+    }
+
+
+    /**
+     * 设置通知图标
+     *
+     * @param icon 通知栏图标 默认取App的icon)
+     */
+    public void setNotificationIcon(@DrawableRes int icon) {
+        this.mNotificationIcon = icon;
+    }
+
+    public int getNotificationIcon() {
+        return this.mNotificationIcon;
+    }
+
+    public boolean isInstallApk() {
+        return isInstallApk;
+    }
+
+    /**
+     * 设置下载完成后知否自动触发安装APK
+     *
+     * @param isInstallApk 下载完成后是否自动调用安装APK(默认true)
+     */
+    public void setInstallApk(boolean isInstallApk) {
+        this.isInstallApk = isInstallApk;
+    }
+
+    public String getAuthority() {
+        return mAuthority;
+    }
+
+    /**
+     * 设置FileProvider的authority
+     *
+     * @param authority FileProvider的authority(默认兼容N,默认值{@link Context#getPackageName() + ".AppUpdaterFileProvider"})
+     */
+    public void setAuthority(String authority) {
+        this.mAuthority = authority;
+    }
+
+    public boolean isShowPercentage() {
+        return isShowPercentage;
+    }
+
+    /**
+     * 设置下载时,通知栏是否显示下载百分比
+     *
+     * @param showPercentage 下载时通知栏是否显示百分比
+     */
+    public void setShowPercentage(boolean showPercentage) {
+        isShowPercentage = showPercentage;
+    }
+
+    public boolean isReDownload() {
+        return isReDownload;
+    }
+
+    /**
+     * 设置下载失败时,是否支持点击通知栏重新下载。与之相关联的方法{@link #setReDownloads(int)}
+     *
+     * @param reDownload 下载失败时是否支持点击通知栏重新下载,默认true
+     */
+    public void setReDownload(boolean reDownload) {
+        isReDownload = reDownload;
+    }
+
+    public int getReDownloads() {
+        return reDownloads;
+    }
+
+    /**
+     * 设置下载失败时,最多重新下载次数。与之相关联的方法{@link #setReDownload(boolean)}
+     *
+     * @param reDownloads 下载失败时是否支持点击通知栏重新下载,默认最多重新下载3次
+     */
+    public void setReDownloads(int reDownloads) {
+        this.reDownloads = reDownloads;
+    }
+
+    public boolean isVibrate() {
+        return isVibrate;
+    }
+
+    /**
+     * 设置通知是否震动提示
+     *
+     * @param vibrate 是否震动提示,为true时使用通知默认震动,Android O(8.0)以上设置,只有初次创建channel时有效,后续修改属性无效,想要重新有效需修改channelId或卸载App重装。
+     */
+    public void setVibrate(boolean vibrate) {
+        isVibrate = vibrate;
+    }
+
+    public boolean isSound() {
+        return isSound;
+    }
+
+    /**
+     * 设置通知是否铃声提示
+     *
+     * @param sound 是否铃声提示,为true时使用通知默认铃声,Android O(8.0)以上设置,只有初次创建channel时有效,后续修改属性无效,想要重新有效需修改channelId或卸载App重装。
+     */
+    public void setSound(boolean sound) {
+        isSound = sound;
+    }
+
+    public long getVersionCode() {
+        return versionCode;
+    }
+
+    /**
+     * 设置要下载APK的versionCode,用于优先取缓存时通过versionCode校验APK文件是否一致。
+     * 缓存校验目前支持两种方式,一种是通过versionCode校验,即{@link #setVersionCode(long)};一种是文件MD5校验,即{@link #setApkMD5(String)}。推荐使用MD5校验方式
+     * 如果两种方式都设置了,则只校验MD5
+     *
+     * @param versionCode 为null表示不处理,默认不存在则下载,存在则重新下载。不为null时,表示会优先校验本地是否存在已下载版本号为versionCode的APK。
+     *                    如果存在则不会重新下载(AppUpdater会自动校验packageName一致性),直接取本地APK,反之重新下载。
+     */
+    public void setVersionCode(long versionCode) {
+        this.versionCode = versionCode;
+    }
+
+    public Map<String, String> getRequestProperty() {
+        return mRequestProperty;
+    }
+
+    /**
+     * 设置APK文件的MD5,用于优先取缓存时通过MD5校验文件APK是否一致。
+     * 缓存校验目前支持两种方式,一种是通过versionCode校验,即{@link #setVersionCode(long)};一种是文件MD5校验,即{@link #setApkMD5(String)}。推荐使用MD5校验方式
+     * 如果两种方式都设置了,则只校验MD5
+     *
+     * @param md5 为null表示不处理,如果设置了MD5,则缓存APK的MD5相同时,只下载一次,优先取本地缓存
+     */
+    public void setApkMD5(String md5) {
+        this.apkMD5 = md5;
+    }
+
+    public String getApkMD5() {
+        return apkMD5;
+    }
+
+    /**
+     * 请求头添加参数
+     *
+     * @param key
+     * @param value
+     */
+    public void addHeader(String key, String value) {
+        initRequestProperty();
+        mRequestProperty.put(key, value);
+    }
+
+    /**
+     * 请求头添加参数
+     *
+     * @param headers
+     */
+    public void addHeader(Map<String, String> headers) {
+        initRequestProperty();
+        mRequestProperty.putAll(headers);
+    }
+
+    private void initRequestProperty() {
+        if (mRequestProperty == null) {
+            mRequestProperty = new HashMap<>();
+        }
+    }
+
+    public boolean isDeleteCancelFile() {
+        return isDeleteCancelFile;
+    }
+
+    /**
+     * 设置是否自动删除取消下载的文件
+     *
+     * @param deleteCancelFile 是否删除取消下载的文件(默认为:true)
+     */
+    public void setDeleteCancelFile(boolean deleteCancelFile) {
+        isDeleteCancelFile = deleteCancelFile;
+    }
+
+
+    public boolean isSupportCancelDownload() {
+        return isSupportCancelDownload;
+    }
+
+    /**
+     * 是否支持通过删除通知栏来取消下载(默认为:false)
+     *
+     * @param cancelDownload
+     * @return
+     * @deprecated 此方法已标记为废弃,后续可能会删除;请使用 {@link #setSupportCancelDownload(boolean)}
+     */
+    @Deprecated
+    public void setCancelDownload(boolean cancelDownload) {
+        setSupportCancelDownload(cancelDownload);
+    }
+
+    /**
+     * 是否支持通过删除通知栏来取消下载(默认为:false)
+     *
+     * @param supportCancelDownload
+     */
+    public void setSupportCancelDownload(boolean supportCancelDownload) {
+        isSupportCancelDownload = supportCancelDownload;
+    }
+
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(this.mUrl);
+        dest.writeString(this.mPath);
+        dest.writeString(this.mFilename);
+        dest.writeByte(this.isShowNotification ? (byte) 1 : (byte) 0);
+        dest.writeByte(this.isInstallApk ? (byte) 1 : (byte) 0);
+        dest.writeInt(this.mNotificationIcon);
+        dest.writeInt(this.mNotificationId);
+        dest.writeString(this.mChannelId);
+        dest.writeString(this.mChannelName);
+        dest.writeString(this.mAuthority);
+        dest.writeByte(this.isReDownload ? (byte) 1 : (byte) 0);
+        dest.writeInt(this.reDownloads);
+        dest.writeByte(this.isShowPercentage ? (byte) 1 : (byte) 0);
+        dest.writeByte(this.isVibrate ? (byte) 1 : (byte) 0);
+        dest.writeByte(this.isSound ? (byte) 1 : (byte) 0);
+        dest.writeLong(this.versionCode);
+        if (mRequestProperty != null) {
+            dest.writeInt(this.mRequestProperty.size());
+            for (Map.Entry<String, String> entry : this.mRequestProperty.entrySet()) {
+                dest.writeString(entry.getKey());
+                dest.writeString(entry.getValue());
+            }
+        } else {
+            dest.writeInt(0);
+        }
+
+        dest.writeByte(this.isDeleteCancelFile ? (byte) 1 : (byte) 0);
+        dest.writeByte(this.isSupportCancelDownload ? (byte) 1 : (byte) 0);
+        dest.writeString(this.apkMD5);
+    }
+
+    protected UpdateConfig(Parcel in) {
+        this.mUrl = in.readString();
+        this.mPath = in.readString();
+        this.mFilename = in.readString();
+        this.isShowNotification = in.readByte() != 0;
+        this.isInstallApk = in.readByte() != 0;
+        this.mNotificationIcon = in.readInt();
+        this.mNotificationId = in.readInt();
+        this.mChannelId = in.readString();
+        this.mChannelName = in.readString();
+        this.mAuthority = in.readString();
+        this.isReDownload = in.readByte() != 0;
+        this.reDownloads = in.readInt();
+        this.isShowPercentage = in.readByte() != 0;
+        this.isVibrate = in.readByte() != 0;
+        this.isSound = in.readByte() != 0;
+        this.versionCode = in.readLong();
+        int mRequestPropertySize = in.readInt();
+        this.mRequestProperty = new HashMap<>(mRequestPropertySize);
+        for (int i = 0; i < mRequestPropertySize; i++) {
+            String key = in.readString();
+            String value = in.readString();
+            this.mRequestProperty.put(key, value);
+        }
+        this.isDeleteCancelFile = in.readByte() != 0;
+        this.isSupportCancelDownload = in.readByte() != 0;
+        this.apkMD5 = in.readString();
+    }
+
+    public static final Creator<UpdateConfig> CREATOR = new Creator<UpdateConfig>() {
+        @Override
+        public UpdateConfig createFromParcel(Parcel source) {
+            return new UpdateConfig(source);
+        }
+
+        @Override
+        public UpdateConfig[] newArray(int size) {
+            return new UpdateConfig[size];
+        }
+    };
+}

+ 26 - 0
home/src/main/java/com/yingyangfly/home/updater/callback/AppUpdateCallback.java

@@ -0,0 +1,26 @@
+package com.yingyangfly.home.updater.callback;
+
+/**
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public abstract class AppUpdateCallback implements UpdateCallback {
+    @Override
+    public void onDownloading(boolean isDownloading) {
+
+    }
+
+    @Override
+    public void onStart(String url) {
+
+    }
+
+    @Override
+    public void onError(Exception e) {
+
+    }
+
+    @Override
+    public void onCancel() {
+
+    }
+}

+ 51 - 0
home/src/main/java/com/yingyangfly/home/updater/callback/UpdateCallback.java

@@ -0,0 +1,51 @@
+package com.yingyangfly.home.updater.callback;
+
+import java.io.File;
+
+/**
+ * 更新回调接口
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public interface UpdateCallback {
+
+    /**
+     * 最开始调用(在onStart之前调用)
+     *
+     * @param isDownloading 为true时,表示已经在下载;为false时,表示当前未开始下载,即将开始下载
+     */
+    void onDownloading(boolean isDownloading);
+
+    /**
+     * 开始
+     */
+    void onStart(String url);
+
+    /**
+     * 更新进度
+     *
+     * @param progress  当前进度大小
+     * @param total     总文件大小
+     * @param isChanged 进度百分比是否有改变,(主要可以用来过滤无用的刷新,从而降低刷新频率)
+     */
+    void onProgress(long progress, long total, boolean isChanged);
+
+    /**
+     * 完成
+     *
+     * @param file APK文件
+     */
+    void onFinish(File file);
+
+    /**
+     * 错误
+     *
+     * @param e 异常
+     */
+    void onError(Exception e);
+
+    /**
+     * 取消
+     */
+    void onCancel();
+}

+ 32 - 0
home/src/main/java/com/yingyangfly/home/updater/constant/Constants.java

@@ -0,0 +1,32 @@
+package com.yingyangfly.home.updater.constant;
+
+/**
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public final class Constants {
+
+    public static final String TAG = "AppUpdater";
+
+    public static final String KEY_UPDATE_CONFIG = "app_update_config";
+
+    public static final int DEFAULT_NOTIFICATION_ID = 0x66;
+
+    public static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "0x66";
+
+    public static final String DEFAULT_NOTIFICATION_CHANNEL_NAME = "AppUpdater";
+
+    public static final String KEY_STOP_DOWNLOAD_SERVICE = "stop_download_service";
+
+    public static final String KEY_RE_DOWNLOAD = "app_update_re_download";
+
+    public static final int RE_CODE_STORAGE_PERMISSION = 0x66;
+
+    public static final int NONE = -1;
+
+    public static final String DEFAULT_FILE_PROVIDER = ".AppUpdaterFileProvider";
+
+    public static final String DEFAULT_DIR = "apk";
+
+    public static final long MINIMUM_INTERVAL_MILLIS = 200L;
+
+}

+ 237 - 0
home/src/main/java/com/yingyangfly/home/updater/http/HttpManager.java

@@ -0,0 +1,237 @@
+package com.yingyangfly.home.updater.http;
+
+import android.os.AsyncTask;
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+import com.yingyangfly.home.updater.util.LogUtils;
+import com.yingyangfly.home.updater.util.SSLSocketFactoryUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.net.ConnectException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Map;
+
+import javax.net.ssl.HttpsURLConnection;
+
+/**
+ * HttpManager使用 {@link HttpURLConnection} 实现的 {@link IHttpManager}
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class HttpManager implements IHttpManager {
+
+    private static final int HTTP_TEMP_REDIRECT = 307;
+    private static final int HTTP_PERM_REDIRECT = 308;
+
+    private static final int DEFAULT_TIME_OUT = 20000;
+
+    private int mTimeout;
+
+    private DownloadTask mDownloadTask;
+
+    private static volatile HttpManager INSTANCE;
+
+    public static HttpManager getInstance() {
+        if (INSTANCE == null) {
+            synchronized (HttpManager.class) {
+                if (INSTANCE == null) {
+                    INSTANCE = new HttpManager();
+                }
+            }
+        }
+        return INSTANCE;
+    }
+
+    private HttpManager() {
+        this(DEFAULT_TIME_OUT);
+    }
+
+    /**
+     * HttpManager对外暴露。如果没有特殊需求,推荐使用{@link HttpManager#getInstance()}
+     */
+    public HttpManager(int timeout) {
+        this.mTimeout = timeout;
+    }
+
+    @Override
+    public void download(String url, String saveFilePath, @Nullable Map<String, String> requestProperty, DownloadCallback callback) {
+        mDownloadTask = new DownloadTask(url, saveFilePath, mTimeout, requestProperty, callback);
+        mDownloadTask.execute();
+    }
+
+    @Override
+    public void cancel() {
+        if (mDownloadTask != null) {
+            mDownloadTask.isCancel = true;
+        }
+    }
+
+    /**
+     * 异步下载任务
+     */
+    private static class DownloadTask extends AsyncTask<Void, Long, File> {
+
+        private String url;
+
+        private String saveFilePath;
+
+        private Map<String, String> requestProperty;
+
+        private DownloadCallback callback;
+
+        private Exception exception;
+
+        private int timeout;
+
+        private volatile boolean isCancel;
+
+        public DownloadTask(String url, String saveFilePath, int timeout, @Nullable Map<String, String> requestProperty, DownloadCallback callback) {
+            this.url = url;
+            this.saveFilePath = saveFilePath;
+            this.timeout = timeout;
+            this.callback = callback;
+            this.requestProperty = requestProperty;
+        }
+
+        private File download(String url) throws Exception {
+            HttpURLConnection connect = (HttpURLConnection) new URL(url).openConnection();
+            connect.setRequestMethod("GET");
+            connect.setRequestProperty("Accept-Encoding", "identity");
+
+            connect.setReadTimeout(timeout);
+            connect.setConnectTimeout(timeout);
+
+            if (requestProperty != null) {
+                for (Map.Entry<String, String> entry : requestProperty.entrySet()) {
+                    connect.setRequestProperty(entry.getKey(), entry.getValue());
+                }
+            }
+
+            connect.connect();
+
+            LogUtils.d("Content-Type: " + connect.getContentType());
+            int responseCode = connect.getResponseCode();
+            switch (responseCode) {
+                case HttpURLConnection.HTTP_OK: {
+                    InputStream is = connect.getInputStream();
+
+                    long length = connect.getContentLength();
+
+                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+                        length = connect.getContentLengthLong();
+                    }
+
+                    LogUtils.d("contentLength: " + length);
+
+                    long progress = 0;
+
+                    byte[] buffer = new byte[4096];
+
+                    int len;
+                    File file = new File(saveFilePath);
+                    FileOutputStream fos = new FileOutputStream(file);
+                    while ((len = is.read(buffer)) != -1) {
+                        if (isCancel) {
+                            cancel(true);
+                            break;
+                        }
+                        fos.write(buffer, 0, len);
+                        progress += len;
+                        // 更新进度
+                        publishProgress(progress, length);
+                    }
+
+                    fos.flush();
+                    fos.close();
+                    is.close();
+
+                    connect.disconnect();
+
+                    if (progress <= 0 && length <= 0) {
+                        throw new IllegalStateException(String.format("contentLength = %d", length));
+                    }
+
+                    return file;
+                }
+                case HttpURLConnection.HTTP_MULT_CHOICE:
+                case HttpURLConnection.HTTP_MOVED_PERM:
+                case HttpURLConnection.HTTP_MOVED_TEMP:
+                case HttpURLConnection.HTTP_SEE_OTHER:
+                case HTTP_TEMP_REDIRECT:
+                case HTTP_PERM_REDIRECT: {
+                    // 重定向
+                    String redirectUrl = connect.getHeaderField("Location");
+                    LogUtils.d("redirectUrl = " + redirectUrl);
+                    connect.disconnect();
+                    return download(redirectUrl);
+                }
+                default:
+                    // 连接失败
+                    throw new ConnectException(String.format("responseCode = %d", responseCode));
+
+            }
+        }
+
+        @Override
+        protected File doInBackground(Void... voids) {
+            try {
+                if (url.startsWith("https")) {
+                    HttpsURLConnection.setDefaultSSLSocketFactory(SSLSocketFactoryUtils.createSSLSocketFactory());
+                    HttpsURLConnection.setDefaultHostnameVerifier(SSLSocketFactoryUtils.createAllowAllHostnameVerifier());
+                }
+                return download(url);
+            } catch (Exception e) {
+                this.exception = e;
+                e.printStackTrace();
+            }
+
+            return null;
+        }
+
+        @Override
+        protected void onPreExecute() {
+            super.onPreExecute();
+            if (callback != null) {
+                callback.onStart(url);
+            }
+        }
+
+        @Override
+        protected void onPostExecute(File file) {
+            super.onPostExecute(file);
+            if (callback != null) {
+                if (file != null) {
+                    callback.onFinish(file);
+                } else {
+                    callback.onError(exception);
+                }
+
+            }
+        }
+
+        @Override
+        protected void onProgressUpdate(Long... values) {
+            super.onProgressUpdate(values);
+            if (callback != null) {
+                if (!isCancelled()) {
+                    callback.onProgress(values[0], values[1]);
+                }
+
+            }
+        }
+
+        @Override
+        protected void onCancelled() {
+            super.onCancelled();
+            if (callback != null) {
+                callback.onCancel();
+            }
+        }
+    }
+
+}

+ 66 - 0
home/src/main/java/com/yingyangfly/home/updater/http/IHttpManager.java

@@ -0,0 +1,66 @@
+package com.yingyangfly.home.updater.http;
+
+import androidx.annotation.Nullable;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.Map;
+
+/**
+ * IHttpManager 默认提供 {@link HttpManager} 和 {@link OkHttpManager} 两种实现。
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public interface IHttpManager {
+
+    /**
+     * 下载
+     *
+     * @param url
+     * @param saveFilePath
+     * @param requestProperty
+     * @param callback
+     */
+    void download(String url, String saveFilePath, @Nullable Map<String, String> requestProperty, DownloadCallback callback);
+
+    /**
+     * 取消下载
+     */
+    void cancel();
+
+    interface DownloadCallback extends Serializable {
+        /**
+         * 开始
+         *
+         * @param url
+         */
+        void onStart(String url);
+
+        /**
+         * 加载进度…
+         *
+         * @param progress
+         * @param total
+         */
+        void onProgress(long progress, long total);
+
+        /**
+         * 完成
+         *
+         * @param file
+         */
+        void onFinish(File file);
+
+        /**
+         * 错误
+         *
+         * @param e
+         */
+        void onError(Exception e);
+
+        /**
+         * 取消
+         */
+        void onCancel();
+    }
+}

+ 231 - 0
home/src/main/java/com/yingyangfly/home/updater/http/OkHttpManager.java

@@ -0,0 +1,231 @@
+package com.yingyangfly.home.updater.http;
+
+import android.os.AsyncTask;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.yingyangfly.home.updater.util.LogUtils;
+import com.yingyangfly.home.updater.util.SSLSocketFactoryUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.net.ConnectException;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import okhttp3.Call;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * OkHttpManager使用 {@link OkHttpClient} 实现的 {@link IHttpManager}
+ * <p>使用 OkHttpManager 时必须依赖 OkHttp 库
+ *
+ * @author <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class OkHttpManager implements IHttpManager {
+
+    private static final int DEFAULT_TIME_OUT = 20000;
+
+    private OkHttpClient okHttpClient;
+
+    private DownloadTask mDownloadTask;
+
+    private static volatile OkHttpManager INSTANCE;
+
+    public static OkHttpManager getInstance() {
+        if (INSTANCE == null) {
+            synchronized (HttpManager.class) {
+                if (INSTANCE == null) {
+                    INSTANCE = new OkHttpManager();
+                }
+            }
+        }
+
+        return INSTANCE;
+    }
+
+    private OkHttpManager() {
+        this(DEFAULT_TIME_OUT);
+    }
+
+    /**
+     * HttpManager对外暴露。如果没有特殊需求,推荐使用{@link HttpManager#getInstance()}
+     *
+     * @param timeout 超时时间,单位:毫秒
+     */
+    public OkHttpManager(int timeout) {
+        this(new OkHttpClient.Builder()
+                .readTimeout(timeout, TimeUnit.MILLISECONDS)
+                .connectTimeout(timeout, TimeUnit.MILLISECONDS)
+                .sslSocketFactory(SSLSocketFactoryUtils.createSSLSocketFactory(), SSLSocketFactoryUtils.createTrustAllX509TrustManager())
+                .hostnameVerifier(SSLSocketFactoryUtils.createAllowAllHostnameVerifier())
+                .build());
+    }
+
+    /**
+     * HttpManager对外暴露,推荐使用{@link HttpManager#getInstance()}
+     *
+     * @param okHttpClient {@link OkHttpClient}
+     */
+    public OkHttpManager(@NonNull OkHttpClient okHttpClient) {
+        this.okHttpClient = okHttpClient;
+    }
+
+
+    @Override
+    public void download(String url, String saveFilePath, @Nullable Map<String, String> requestProperty, final DownloadCallback callback) {
+        mDownloadTask = new DownloadTask(okHttpClient, url, saveFilePath, requestProperty, callback);
+        mDownloadTask.execute();
+    }
+
+    @Override
+    public void cancel() {
+        if (mDownloadTask != null) {
+            mDownloadTask.isCancel = true;
+        }
+    }
+
+
+    /**
+     * 异步下载任务
+     */
+    private static class DownloadTask extends AsyncTask<Void, Long, File> {
+
+        private String url;
+
+        private String saveFilePath;
+
+        private Map<String, String> requestProperty;
+
+        private DownloadCallback callback;
+
+        private Exception exception;
+
+        private OkHttpClient okHttpClient;
+
+        private volatile boolean isCancel;
+
+        public DownloadTask(OkHttpClient okHttpClient, String url, String saveFilePath, @Nullable Map<String, String> requestProperty, DownloadCallback callback) {
+            this.okHttpClient = okHttpClient;
+            this.url = url;
+            this.saveFilePath = saveFilePath;
+            this.callback = callback;
+            this.requestProperty = requestProperty;
+
+        }
+
+        @Override
+        protected File doInBackground(Void... voids) {
+            try {
+                Request.Builder builder = new Request.Builder()
+                        .url(url)
+                        .addHeader("Accept-Encoding", "identity")
+                        .get();
+
+                if (requestProperty != null) {
+                    for (Map.Entry<String, String> entry : requestProperty.entrySet()) {
+                        builder.addHeader(entry.getKey(), entry.getValue());
+                    }
+                }
+
+                Call call = okHttpClient.newCall(builder.build());
+                Response response = call.execute();
+
+                if (response.isSuccessful()) {
+                    InputStream is = response.body().byteStream();
+
+                    long length = response.body().contentLength();
+
+                    LogUtils.d("contentLength: " + length);
+
+                    long progress = 0;
+
+                    byte[] buffer = new byte[4096];
+
+                    int len;
+                    File file = new File(saveFilePath);
+                    FileOutputStream fos = new FileOutputStream(file);
+                    while ((len = is.read(buffer)) != -1) {
+                        if (isCancel) {
+                            if (call != null) {
+                                call.cancel();
+                            }
+                            cancel(true);
+                            break;
+                        }
+                        fos.write(buffer, 0, len);
+                        progress += len;
+                        // 更新进度
+                        publishProgress(progress, length);
+                    }
+
+                    fos.flush();
+                    fos.close();
+                    is.close();
+
+                    response.close();
+
+                    if (progress <= 0 && length <= 0) {
+                        throw new IllegalStateException(String.format("contentLength = %d", length));
+                    }
+
+                    return file;
+
+                } else {// 连接失败
+                    throw new ConnectException(String.format("responseCode = %d", response.code()));
+                }
+
+            } catch (Exception e) {
+                this.exception = e;
+                e.printStackTrace();
+            }
+
+            return null;
+        }
+
+        @Override
+        protected void onPreExecute() {
+            super.onPreExecute();
+            if (callback != null) {
+                callback.onStart(url);
+            }
+        }
+
+        @Override
+        protected void onPostExecute(File file) {
+            super.onPostExecute(file);
+            if (callback != null) {
+                if (file != null) {
+                    callback.onFinish(file);
+                } else {
+                    callback.onError(exception);
+                }
+
+            }
+        }
+
+        @Override
+        protected void onProgressUpdate(Long... values) {
+            super.onProgressUpdate(values);
+            if (callback != null) {
+                if (!isCancelled()) {
+                    callback.onProgress(values[0], values[1]);
+                }
+
+            }
+        }
+
+        @Override
+        protected void onCancelled() {
+            super.onCancelled();
+            if (callback != null) {
+                callback.onCancel();
+            }
+        }
+
+    }
+}

+ 83 - 0
home/src/main/java/com/yingyangfly/home/updater/notify/INotification.java

@@ -0,0 +1,83 @@
+package com.yingyangfly.home.updater.notify;
+
+import android.content.Context;
+
+import androidx.annotation.DrawableRes;
+
+import com.yingyangfly.home.updater.UpdateConfig;
+
+import java.io.File;
+
+/**
+ * 通知栏进度更新
+ *
+ * @author <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public interface INotification {
+    /**
+     * 开始
+     *
+     * @param context                 上下文
+     * @param notifyId                通知ID
+     * @param channelId               通知通道ID
+     * @param channelName             通知通道名称
+     * @param smallIcon               通知图标
+     * @param title                   通知标题
+     * @param content                 通知内容
+     * @param isVibrate               通知是否允许震动
+     * @param isSound                 通知是否有铃声
+     * @param isSupportCancelDownload 是否支持取消下载
+     */
+    void onStart(Context context, int notifyId, String channelId, String channelName, @DrawableRes int smallIcon, CharSequence title, CharSequence content, boolean isVibrate, boolean isSound, boolean isSupportCancelDownload);
+
+    /**
+     * 更新进度
+     *
+     * @param context                 上下文
+     * @param notifyId                通知ID
+     * @param channelId               通知通道ID
+     * @param smallIcon               通知图标
+     * @param title                   通知标题
+     * @param content                 通知内容
+     * @param progress                当前进度大小
+     * @param size                    总进度大小
+     * @param isSupportCancelDownload 是否支持取消下载
+     */
+    void onProgress(Context context, int notifyId, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, int progress, int size, boolean isSupportCancelDownload);
+
+    /**
+     * 完成
+     *
+     * @param context   上下文
+     * @param notifyId  通知ID
+     * @param channelId 通知通道ID
+     * @param smallIcon 通知图标
+     * @param title     通知标题
+     * @param content   通知内容
+     * @param file      APK文件
+     * @param authority 文件访问授权
+     */
+    void onFinish(Context context, int notifyId, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, File file, String authority);
+
+    /**
+     * 错误
+     *
+     * @param context      上下文
+     * @param notifyId     通知ID
+     * @param channelId    通知通道ID
+     * @param smallIcon    通知图标
+     * @param title        通知标题
+     * @param content      通知内容
+     * @param isReDownload 是否重复下载
+     * @param config       配置
+     */
+    void onError(Context context, int notifyId, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, boolean isReDownload, UpdateConfig config);
+
+    /**
+     * 取消
+     *
+     * @param context  上下文
+     * @param notifyId 通知ID
+     */
+    void onCancel(Context context, int notifyId);
+}

+ 41 - 0
home/src/main/java/com/yingyangfly/home/updater/notify/NotificationImpl.java

@@ -0,0 +1,41 @@
+package com.yingyangfly.home.updater.notify;
+
+import android.content.Context;
+
+import com.yingyangfly.home.updater.UpdateConfig;
+import com.yingyangfly.home.updater.util.NotificationUtils;
+
+import java.io.File;
+
+/**
+ * {@link INotification} 的实现,如果需要自定义通知栏的布局,并对 {@link NotificationImpl} 的实现不满意,可通过自定义去实现一个 {@link INotification}
+ *
+ * @author <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class NotificationImpl implements INotification {
+
+    @Override
+    public void onStart(Context context, int notifyId, String channelId, String channelName, int smallIcon, CharSequence title, CharSequence content, boolean isVibrate, boolean isSound, boolean isSupportCancelDownload) {
+        NotificationUtils.showStartNotification(context, notifyId, channelId, channelName, smallIcon, title, content, isVibrate, isSound, isSupportCancelDownload);
+    }
+
+    @Override
+    public void onProgress(Context context, int notifyId, String channelId, int smallIcon, CharSequence title, CharSequence content, int progress, int size, boolean isSupportCancelDownload) {
+        NotificationUtils.showProgressNotification(context, notifyId, channelId, smallIcon, title, content, progress, size, isSupportCancelDownload);
+    }
+
+    @Override
+    public void onFinish(Context context, int notifyId, String channelId, int smallIcon, CharSequence title, CharSequence content, File file, String authority) {
+        NotificationUtils.showFinishNotification(context, notifyId, channelId, smallIcon, title, content, file, authority);
+    }
+
+    @Override
+    public void onError(Context context, int notifyId, String channelId, int smallIcon, CharSequence title, CharSequence content, boolean isReDownload, UpdateConfig config) {
+        NotificationUtils.showErrorNotification(context, notifyId, channelId, smallIcon, title, content, isReDownload, config);
+    }
+
+    @Override
+    public void onCancel(Context context, int notifyId) {
+        NotificationUtils.cancelNotification(context, notifyId);
+    }
+}

+ 10 - 0
home/src/main/java/com/yingyangfly/home/updater/provider/AppUpdaterFileProvider.java

@@ -0,0 +1,10 @@
+package com.yingyangfly.home.updater.provider;
+
+import androidx.core.content.FileProvider;
+
+/**
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class AppUpdaterFileProvider extends FileProvider {
+
+}

+ 504 - 0
home/src/main/java/com/yingyangfly/home/updater/service/DownloadService.java

@@ -0,0 +1,504 @@
+package com.yingyangfly.home.updater.service;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IBinder;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import com.yingyang.home.R;
+import com.yingyangfly.home.updater.UpdateConfig;
+import com.yingyangfly.home.updater.callback.UpdateCallback;
+import com.yingyangfly.home.updater.constant.Constants;
+import com.yingyangfly.home.updater.http.HttpManager;
+import com.yingyangfly.home.updater.http.IHttpManager;
+import com.yingyangfly.home.updater.notify.INotification;
+import com.yingyangfly.home.updater.notify.NotificationImpl;
+import com.yingyangfly.home.updater.util.AppUtils;
+import com.yingyangfly.home.updater.util.LogUtils;
+
+import java.io.File;
+import java.util.Locale;
+
+/**
+ * 下载服务
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class DownloadService extends Service {
+    /**
+     * DownloadBinder
+     */
+    private DownloadBinder mDownloadBinder = new DownloadBinder();
+    /**
+     * 是否在下载,防止重复下载。
+     */
+    private boolean isDownloading;
+    /**
+     * 失败后重新下载次数
+     */
+    private int mCount = 0;
+    /**
+     * Http管理器
+     */
+    private IHttpManager mHttpManager;
+    /**
+     * 更新回调
+     */
+    private UpdateCallback mUpdateCallback;
+    /**
+     * 通知栏
+     */
+    private INotification mNotification;
+    /**
+     * APK文件
+     */
+    private File mApkFile;
+
+    /**
+     * 获取Context
+     *
+     * @return
+     */
+    private Context getContext() {
+        return this;
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        if (intent != null) {
+            boolean isStop = intent.getBooleanExtra(Constants.KEY_STOP_DOWNLOAD_SERVICE, false);
+            if (isStop) {
+                stopDownload();
+            } else if (!isDownloading) {
+                // 是否实通过通知栏触发重复下载
+                boolean isReDownload = intent.getBooleanExtra(Constants.KEY_RE_DOWNLOAD, false);
+                if (isReDownload) {
+                    mCount++;
+                }
+                // 获取配置信息
+                UpdateConfig config = intent.getParcelableExtra(Constants.KEY_UPDATE_CONFIG);
+
+                startDownload(config);
+            } else {
+                LogUtils.w("Please do not repeat the download.");
+            }
+        }
+
+        return super.onStartCommand(intent, flags, startId);
+
+    }
+
+
+    //----------------------------------------
+
+    /**
+     * 开始下载
+     *
+     * @param config
+     */
+    private void startDownload(@NonNull UpdateConfig config) {
+        startDownload(config, mHttpManager, mUpdateCallback, mNotification);
+    }
+
+    /**
+     * 开始下载
+     *
+     * @param config
+     * @param httpManager
+     * @param callback
+     */
+    private void startDownload(@NonNull UpdateConfig config, @Nullable IHttpManager httpManager, @Nullable UpdateCallback callback, @Nullable INotification notification) {
+        if (callback != null) {
+            callback.onDownloading(isDownloading);
+        }
+
+        if (isDownloading) {
+            LogUtils.w("Please do not repeat the download.");
+            return;
+        }
+
+        String url = config.getUrl();
+        String path = config.getPath();
+        String filename = config.getFilename();
+
+        // 如果保存路径为空则使用缓存路径
+        if (TextUtils.isEmpty(path)) {
+            path = AppUtils.getApkCacheFilesDir(getContext());
+        }
+        File dirFile = new File(path);
+        if (!dirFile.exists()) {
+            dirFile.mkdirs();
+        }
+
+        // 如果文件名为空则使用路径
+        if (TextUtils.isEmpty(filename)) {
+            filename = AppUtils.getAppFullName(getContext(), url, getResources().getString(R.string.app_name));
+        }
+
+        mApkFile = new File(path, filename);
+        // 文件是否存在
+        if (mApkFile.exists()) {
+            long versionCode = config.getVersionCode();
+            String apkMD5 = config.getApkMD5();
+            // 是否存在相同的apk
+            boolean isExistApk = false;
+            if (!TextUtils.isEmpty(apkMD5)) {
+                // 如果存在MD5,则优先校验MD5
+                LogUtils.d(String.format(Locale.getDefault(), "UpdateConfig.apkMD5: %s", apkMD5));
+                isExistApk = AppUtils.verifyFileMD5(mApkFile, apkMD5);
+            } else if (versionCode > 0) {
+                // 如果存在versionCode,则校验versionCode
+                LogUtils.d(String.format(Locale.getDefault(), "UpdateConfig.versionCode: %d", versionCode));
+                isExistApk = AppUtils.apkExists(getContext(), versionCode, mApkFile);
+            }
+
+            if (isExistApk) {
+                // 本地已经存在要下载的APK
+                LogUtils.d("CacheFile: " + mApkFile);
+                if (config.isInstallApk()) {
+                    String authority = config.getAuthority();
+                    // 如果为空则默认
+                    if (TextUtils.isEmpty(authority)) {
+                        authority = AppUtils.getFileProviderAuthority(getContext());
+                    }
+                    AppUtils.installApk(getContext(), mApkFile, authority);
+                }
+                if (callback != null) {
+                    callback.onFinish(mApkFile);
+                }
+                stopService();
+                return;
+            }
+
+            // 删除旧文件
+            mApkFile.delete();
+        }
+        LogUtils.d("File: " + mApkFile);
+        this.mUpdateCallback = callback;
+        IHttpManager.DownloadCallback downloadCallback = new AppDownloadCallback(getContext(), this, config, mApkFile, callback, getNotification(notification));
+        getHttpManager(httpManager).download(url, mApkFile.getAbsolutePath(), config.getRequestProperty(), downloadCallback);
+
+    }
+
+    /**
+     * 获取 IHttpManager
+     *
+     * @param httpManager {@link IHttpManager}
+     * @return
+     */
+    @NonNull
+    private IHttpManager getHttpManager(@Nullable IHttpManager httpManager) {
+        if (httpManager != null) {
+            mHttpManager = httpManager;
+        }
+        if (mHttpManager == null) {
+            mHttpManager = HttpManager.getInstance();
+        }
+        return mHttpManager;
+    }
+
+    /**
+     * 获取 INotification
+     *
+     * @param notification {@link INotification}
+     * @return
+     */
+    @NonNull
+    private INotification getNotification(@Nullable INotification notification) {
+        if (notification != null) {
+            mNotification = notification;
+        }
+        if (mNotification == null) {
+            mNotification = new NotificationImpl();
+        }
+        return mNotification;
+    }
+
+    /**
+     * 停止下载
+     */
+    private void stopDownload() {
+        if (mHttpManager != null) {
+            mHttpManager.cancel();
+        }
+    }
+
+    /**
+     * 停止服务
+     */
+    private void stopService() {
+        mCount = 0;
+        stopSelf();
+    }
+
+
+    //---------------------------------------- DownloadCallback
+
+    /**
+     * App下载回调接口
+     */
+    public static class AppDownloadCallback implements IHttpManager.DownloadCallback {
+
+        private Context context;
+
+        private DownloadService downloadService;
+
+        public UpdateConfig config;
+
+        private boolean isShowNotification;
+
+        private int notifyId;
+
+        private String channelId;
+
+        private String channelName;
+
+        private int notificationIcon;
+
+        private boolean isInstallApk;
+
+        private String authority;
+
+        private boolean isShowPercentage;
+
+        private boolean isReDownload;
+
+        private boolean isDeleteCancelFile;
+
+        private boolean isSupportCancelDownload;
+
+        private UpdateCallback callback;
+
+        private INotification notification;
+
+        /**
+         * 最后更新进度,用来降频刷新
+         */
+        private int lastProgress;
+        /**
+         * 最后进度更新时间,用来降频刷新
+         */
+        private long lastTime;
+        /**
+         * APK文件
+         */
+        private File apkFile;
+
+        private AppDownloadCallback(Context context, DownloadService downloadService, UpdateConfig config, File apkFile, UpdateCallback callback, INotification notification) {
+            this.context = context;
+            this.downloadService = downloadService;
+            this.config = config;
+            this.apkFile = apkFile;
+            this.callback = callback;
+            this.notification = notification;
+            this.isShowNotification = config.isShowNotification();
+            this.notifyId = config.getNotificationId();
+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                this.channelId = TextUtils.isEmpty(config.getChannelId()) ? Constants.DEFAULT_NOTIFICATION_CHANNEL_ID : config.getChannelId();
+                this.channelName = TextUtils.isEmpty(config.getChannelName()) ? Constants.DEFAULT_NOTIFICATION_CHANNEL_NAME : config.getChannelName();
+            }
+            if (config.getNotificationIcon() <= 0) {
+                this.notificationIcon = AppUtils.getAppIcon(context);
+            } else {
+                this.notificationIcon = config.getNotificationIcon();
+            }
+
+            this.isInstallApk = config.isInstallApk();
+
+            this.authority = config.getAuthority();
+            // 如果为空则默认
+            if (TextUtils.isEmpty(config.getAuthority())) {
+                authority = AppUtils.getFileProviderAuthority(context);
+            }
+
+            this.isShowPercentage = config.isShowPercentage();
+            this.isDeleteCancelFile = config.isDeleteCancelFile();
+            this.isSupportCancelDownload = config.isSupportCancelDownload();
+
+            // 支持下载失败时重新下载,当重新下载次数不超过限制时才被允许
+            this.isReDownload = config.isReDownload() && downloadService.mCount < config.getReDownloads();
+
+        }
+
+        @Override
+        public void onStart(String url) {
+            LogUtils.i("url: " + url);
+            downloadService.isDownloading = true;
+            lastProgress = 0;
+            if (isShowNotification && notification != null) {
+                notification.onStart(context, notifyId, channelId, channelName, notificationIcon, getString(R.string.app_updater_start_notification_title), getString(R.string.app_updater_start_notification_content), config.isVibrate(), config.isSound(), isSupportCancelDownload);
+            }
+
+            if (callback != null) {
+                callback.onStart(url);
+            }
+        }
+
+        @Override
+        public void onProgress(long progress, long total) {
+            boolean isChanged = false;
+            long curTime = System.currentTimeMillis();
+            // 降低更新频率
+            if (lastTime + Constants.MINIMUM_INTERVAL_MILLIS < curTime || progress == total) {
+                lastTime = curTime;
+                int progressPercentage = 0;
+                if (total > 0) {
+                    progressPercentage = Math.round(progress * 1.0f / total * 100.0f);
+                    // 百分比改变了才更新
+                    if (progressPercentage != lastProgress) {
+                        isChanged = true;
+                        lastProgress = progressPercentage;
+                    }
+                    LogUtils.i(String.format(Locale.getDefault(), "%d%%\t| %d/%d", progressPercentage, progress, total));
+                } else {
+                    LogUtils.i(String.format(Locale.getDefault(), "%d/%d", progress, total));
+                }
+
+                if (isShowNotification && notification != null) {
+                    String content = context.getString(R.string.app_updater_progress_notification_content);
+                    if (total > 0) {
+                        if (isShowPercentage) {
+                            content = String.format(Locale.getDefault(), "%s%d%%", content, progressPercentage);
+                        }
+                        notification.onProgress(context, notifyId, channelId, notificationIcon, context.getString(R.string.app_updater_progress_notification_title), content, progressPercentage, 100, isSupportCancelDownload);
+                    } else {
+                        notification.onProgress(context, notifyId, channelId, notificationIcon, context.getString(R.string.app_updater_progress_notification_title), content, (int) progress, Constants.NONE, isSupportCancelDownload);
+                    }
+                }
+            }
+
+            if (callback != null && total > 0) {
+                callback.onProgress(progress, total, isChanged);
+            }
+
+        }
+
+        @Override
+        public void onFinish(File file) {
+            LogUtils.d("File: " + file);
+            downloadService.isDownloading = false;
+            if (isShowNotification && notification != null) {
+                notification.onFinish(context, notifyId, channelId, notificationIcon, getString(R.string.app_updater_finish_notification_title), getString(R.string.app_updater_finish_notification_content), file, authority);
+            }
+            if (isInstallApk) {
+                AppUtils.installApk(context, file, authority);
+            }
+            if (callback != null) {
+                callback.onFinish(file);
+            }
+            downloadService.stopService();
+        }
+
+        @Override
+        public void onError(Exception e) {
+            LogUtils.w(e.getMessage());
+            downloadService.isDownloading = false;
+            if (isShowNotification && notification != null) {
+                String content = isReDownload ? getString(R.string.app_updater_error_notification_content_re_download) : getString(R.string.app_updater_error_notification_content);
+                notification.onError(context, notifyId, channelId, notificationIcon, getString(R.string.app_updater_error_notification_title), content, isReDownload, config);
+            }
+
+            if (callback != null) {
+                callback.onError(e);
+            }
+            if (!isReDownload) {
+                downloadService.stopService();
+            }
+
+        }
+
+        @Override
+        public void onCancel() {
+            LogUtils.d("Cancel download.");
+            downloadService.isDownloading = false;
+            if (isShowNotification && notification != null) {
+                notification.onCancel(context, notifyId);
+            }
+            if (callback != null) {
+                callback.onCancel();
+            }
+            if (isDeleteCancelFile && apkFile != null) {
+                apkFile.delete();
+            }
+            downloadService.stopService();
+        }
+
+        private String getString(@StringRes int resId) {
+            return context.getString(resId);
+        }
+    }
+
+    @Override
+    public void onDestroy() {
+        isDownloading = false;
+        mHttpManager = null;
+        mUpdateCallback = null;
+        mNotification = null;
+        super.onDestroy();
+    }
+
+    //---------------------------------------- Binder
+
+    @Nullable
+    @Override
+    public IBinder onBind(Intent intent) {
+        return mDownloadBinder;
+    }
+
+    /**
+     * 提供绑定服务的方式进行下载
+     */
+    public class DownloadBinder extends Binder {
+
+        /**
+         * 开始下载
+         *
+         * @param config {@link UpdateConfig}
+         */
+        public void start(@NonNull UpdateConfig config) {
+            start(config, null);
+        }
+
+        /**
+         * 开始下载
+         *
+         * @param config   {@link UpdateConfig}
+         * @param callback {@link UpdateCallback}
+         */
+        public void start(@NonNull UpdateConfig config, @Nullable UpdateCallback callback) {
+            start(config, null, callback);
+        }
+
+        /**
+         * 开始下载
+         *
+         * @param config      {@link UpdateConfig}
+         * @param httpManager {@link IHttpManager}
+         * @param callback    {@link UpdateCallback}
+         */
+        public void start(@NonNull UpdateConfig config, @Nullable IHttpManager httpManager, @Nullable UpdateCallback callback) {
+            start(config, httpManager, callback, null);
+        }
+
+        /**
+         * 开始下载
+         *
+         * @param config       {@link UpdateConfig}
+         * @param httpManager  {@link IHttpManager}
+         * @param callback     {@link UpdateCallback}
+         * @param notification {@link INotification}
+         */
+        public void start(@NonNull UpdateConfig config, @Nullable IHttpManager httpManager, @Nullable UpdateCallback callback, @Nullable INotification notification) {
+            startDownload(config, httpManager, callback, notification);
+        }
+    }
+
+}

+ 359 - 0
home/src/main/java/com/yingyangfly/home/updater/util/AppUtils.java

@@ -0,0 +1,359 @@
+package com.yingyangfly.home.updater.util;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.res.AssetFileDescriptor;
+import android.net.Uri;
+import android.os.Build;
+import android.text.TextUtils;
+
+import androidx.core.content.ContextCompat;
+import androidx.core.content.FileProvider;
+import androidx.core.content.pm.PackageInfoCompat;
+
+import com.yingyangfly.home.updater.constant.Constants;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.util.Locale;
+
+/**
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public final class AppUtils {
+
+    /**
+     * 十六进制字符
+     */
+    private static char hexChars[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
+
+    private AppUtils() {
+        throw new AssertionError();
+    }
+
+    /**
+     * 通过url获取App的全名称
+     *
+     * @param context 上下文
+     * @return 返回App的名称;(例如:AppName.apk)
+     */
+    public static String getAppFullName(Context context, String url, String defaultName) {
+        if (url.endsWith(".apk")) {
+            String apkName = url.substring(url.lastIndexOf("/") + 1);
+            if (apkName.length() <= 64) {
+                return apkName;
+            }
+        }
+
+        String filename = getAppName(context);
+        LogUtils.d("AppName: " + filename);
+        if (TextUtils.isEmpty(filename)) {
+            filename = defaultName;
+        }
+        if (filename.endsWith(".apk")) {
+            return filename;
+        }
+        return String.format("%s.apk", filename);
+    }
+
+    /**
+     * 获取包信息
+     *
+     * @param context 上下文
+     * @return {@link PackageInfo}
+     * @throws PackageManager.NameNotFoundException
+     */
+    public static PackageInfo getPackageInfo(Context context) throws PackageManager.NameNotFoundException {
+        PackageManager packageManager = context.getPackageManager();
+        PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
+        return packageInfo;
+    }
+
+    /**
+     * 通过APK路径获取包信息
+     *
+     * @param context         上下文
+     * @param archiveFilePath 文件路径
+     * @return
+     */
+    public static PackageInfo getPackageInfo(Context context, String archiveFilePath) {
+        PackageManager packageManager = context.getPackageManager();
+        PackageInfo packageInfo = packageManager.getPackageArchiveInfo(archiveFilePath, PackageManager.GET_ACTIVITIES);
+        return packageInfo;
+    }
+
+    /**
+     * 获取App的名称
+     *
+     * @param context 上下文
+     */
+    public static String getAppName(Context context) {
+        try {
+            int labelRes = getPackageInfo(context).applicationInfo.labelRes;
+            return context.getResources().getString(labelRes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return null;
+    }
+
+    /**
+     * 获取App的图标
+     *
+     * @param context 上下文
+     * @return
+     */
+    public static int getAppIcon(Context context) {
+        try {
+            return getPackageInfo(context).applicationInfo.icon;
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return 0;
+    }
+
+    /**
+     * 安装APK
+     *
+     * @param context   上下文
+     * @param file      APK文件
+     * @param authority 文件访问授权
+     */
+    public static void installApk(Context context, File file, String authority) {
+        Intent intent = getInstallIntent(context, file, authority);
+        context.startActivity(intent);
+    }
+
+    /**
+     * 获取安装Intent
+     *
+     * @param context   上下文
+     * @param file      APK文件
+     * @param authority 文件访问授权
+     * @return
+     */
+    public static Intent getInstallIntent(Context context, File file, String authority) {
+        Intent intent = new Intent(Intent.ACTION_VIEW);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        intent.addCategory(Intent.CATEGORY_DEFAULT);
+        Uri uriData;
+        String type = "application/vnd.android.package-archive";
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            uriData = FileProvider.getUriForFile(context, authority, file);
+        } else {
+            uriData = Uri.fromFile(file);
+        }
+        intent.setDataAndType(uriData, type);
+        return intent;
+    }
+
+    /**
+     * APK是否存在
+     *
+     * @param context     上下文
+     * @param versionCode 版本号
+     * @param file        APK文件
+     * @return
+     * @throws Exception
+     */
+    public static boolean apkExists(Context context, long versionCode, File file) {
+        if (file != null && file.exists()) {
+            String packageName = context.getPackageName();
+            PackageInfo packageInfo = AppUtils.getPackageInfo(context, file.getAbsolutePath());
+
+            if (packageInfo != null) {
+                // 比对versionCode
+                long apkVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo);
+                LogUtils.d(String.format(Locale.getDefault(), "ApkVersionCode: %d", apkVersionCode));
+                if (versionCode == apkVersionCode) {
+                    ApplicationInfo applicationInfo = packageInfo.applicationInfo;
+                    if (applicationInfo != null && packageName.equals(applicationInfo.packageName)) {//比对packageName
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断文件是否存在
+     *
+     * @param context 上下文
+     * @param path    文件路径
+     * @return
+     */
+    public static boolean isAndroidQFileExists(Context context, String path) {
+        return isAndroidQFileExists(context, new File(path));
+    }
+
+    /**
+     * 判断文件是否存在
+     *
+     * @param context 上下文
+     * @param file    文件
+     * @return
+     */
+    public static boolean isAndroidQFileExists(Context context, File file) {
+        AssetFileDescriptor descriptor = null;
+        ContentResolver contentResolver = context.getContentResolver();
+        try {
+            Uri uri = Uri.fromFile(file);
+            descriptor = contentResolver.openAssetFileDescriptor(uri, "r");
+            if (descriptor == null) {
+                return false;
+            } else {
+                close(descriptor);
+            }
+            return true;
+        } catch (FileNotFoundException e) {
+            LogUtils.w(e.getMessage());
+        } finally {
+            close(descriptor);
+        }
+        return false;
+    }
+
+    /**
+     * 校验文件的MD5
+     *
+     * @param file 文件
+     * @param md5  MD5
+     * @return 如果文件的MD5与 传入的 MD5字符比对一致,则返回 true,反之返回 false
+     */
+    public static boolean verifyFileMD5(File file, String md5) {
+        String fileMD5 = getFileMD5(file);
+        LogUtils.d("FileMD5: " + fileMD5);
+        if (!TextUtils.isEmpty(md5)) {
+            return md5.equalsIgnoreCase(fileMD5);
+        }
+
+        return false;
+    }
+
+    /**
+     * 获取文件的MD5
+     *
+     * @param file 文件
+     * @return 返回文件的MD5
+     */
+    public static String getFileMD5(File file) {
+        FileInputStream fileInputStream = null;
+        try {
+            fileInputStream = new FileInputStream(file);
+            MessageDigest messageDigest = MessageDigest.getInstance("MD5");
+            byte[] buffer = new byte[8192];
+            int length;
+            while ((length = fileInputStream.read(buffer)) != -1) {
+                messageDigest.update(buffer, 0, length);
+            }
+            return byteArrayToHexString(messageDigest.digest());
+        } catch (Exception e) {
+            e.printStackTrace();
+            return null;
+        } finally {
+            if (fileInputStream != null) {
+                try {
+                    fileInputStream.close();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+
+    }
+
+    /**
+     * 字节转为十六进制字符串
+     *
+     * @param bytes 字节数组
+     * @return 返回十六进制字符串
+     */
+    public static String byteArrayToHexString(byte bytes[]) {
+        String hexString = null;
+        if (bytes != null) {
+            int length = bytes.length;
+            StringBuilder out = new StringBuilder(length * 2);
+            for (int x = 0; x < length; x++) {
+                int nybble = bytes[x] & 0xF0;
+                nybble = nybble >>> 4;
+                out.append(hexChars[nybble]);
+                out.append(hexChars[bytes[x] & 0x0F]);
+            }
+            hexString = out.toString();
+        }
+        return hexString;
+    }
+
+    /**
+     * 获取文件访问授权
+     *
+     * @param context 上下文
+     * @return 返回文件访问授权
+     */
+    public static String getFileProviderAuthority(Context context) {
+        return context.getPackageName() + Constants.DEFAULT_FILE_PROVIDER;
+    }
+
+    /**
+     * 关闭
+     *
+     * @param descriptor {@link AssetFileDescriptor}
+     */
+    private static void close(AssetFileDescriptor descriptor) {
+        if (descriptor != null) {
+            try {
+                descriptor.close();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
+        }
+    }
+
+    /**
+     * 删除文件或文件夹
+     *
+     * @param file
+     */
+    public static boolean deleteFile(File file) {
+        if (file == null || !file.exists()) {
+            return true;
+        }
+        if (file.isFile()) {
+            return file.delete();
+        } else if (file.isDirectory()) {
+            for (File f : file.listFiles()) {
+                if (f.isFile()) {
+                    f.delete(); // 删除所有文件
+                } else if (f.isDirectory()) {
+                    deleteFile(f); // 递规的方式删除文件夹
+                }
+            }
+            // 删除目录本身
+            return file.delete();
+        }
+        return false;
+    }
+
+    /**
+     * 获取APK缓存的文件夹
+     *
+     * @param context
+     * @return
+     */
+    public static String getApkCacheFilesDir(Context context) {
+        File[] files = ContextCompat.getExternalFilesDirs(context, Constants.DEFAULT_DIR);
+        if (files != null && files.length > 0) {
+            return files[0].getAbsolutePath();
+        }
+        return new File(context.getFilesDir(), Constants.DEFAULT_DIR).getAbsolutePath();
+    }
+}

+ 315 - 0
home/src/main/java/com/yingyangfly/home/updater/util/LogUtils.java

@@ -0,0 +1,315 @@
+/*
+ Copyright © 2015, 2016 Jenly Yu <a href="mailto:jenly1314@gmail.com">Jenly</a>
+
+ 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.yingyangfly.home.updater.util;
+
+import android.util.Log;
+
+import java.util.Locale;
+
+/**
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class LogUtils {
+
+    public static final String TAG = "AppUpdater";
+
+    public static final String VERTICAL = "|";
+
+    /** 是否显示Log日志 */
+    private static boolean isShowLog = true;
+
+    /** Log日志优先权 */
+    private static int priority = 1;
+
+    /**
+     * Priority constant for the println method;use System.out.println
+     */
+    public static final int PRINTLN = 1;
+
+    /**
+     * Priority constant for the println method; use Log.v.
+     */
+    public static final int VERBOSE = 2;
+
+    /**
+     * Priority constant for the println method; use Log.d.
+     */
+    public static final int DEBUG = 3;
+
+    /**
+     * Priority constant for the println method; use Log.i.
+     */
+    public static final int INFO = 4;
+
+    /**
+     * Priority constant for the println method; use Log.w.
+     */
+    public static final int WARN = 5;
+
+    /**
+     * Priority constant for the println method; use Log.e.
+     */
+    public static final int ERROR = 6;
+
+    /**
+     * Priority constant for the println method.use Log.wtf.
+     */
+    public static final int ASSERT = 7;
+
+    public static final String TAG_FORMAT = "%s.%s(%s:%d)";
+
+    private LogUtils(){
+        throw new AssertionError();
+    }
+
+    public static void setShowLog(boolean isShowLog) {
+
+        LogUtils.isShowLog = isShowLog;
+    }
+
+    public static boolean isShowLog() {
+
+        return isShowLog;
+    }
+
+    public static int getPriority() {
+
+        return priority;
+    }
+
+    public static void setPriority(int priority) {
+
+        LogUtils.priority = priority;
+    }
+
+    /**
+     * 根据堆栈生成TAG
+     * @return TAG|className.methodName(fileName:lineNumber)
+     */
+    private static String generateTag(StackTraceElement caller) {
+        String tag = TAG_FORMAT;
+        String callerClazzName = caller.getClassName();
+        callerClazzName = callerClazzName.substring(callerClazzName.lastIndexOf(".") + 1);
+        tag = String.format(Locale.getDefault(),tag,callerClazzName, caller.getMethodName(),caller.getFileName(),caller.getLineNumber());
+        return new StringBuilder().append(TAG).append(VERTICAL).append(tag).toString();
+    }
+
+    /**
+     * 获取堆栈
+     * @param n
+     * 		n=0		VMStack
+     * 		n=1		Thread
+     * 		n=3		CurrentStack
+     * 		n=4		CallerStack
+     * 		...
+     * @return
+     */
+    public static StackTraceElement getStackTraceElement(int n) {
+        return Thread.currentThread().getStackTrace()[n];
+    }
+
+    /**
+     * 获取调用方的堆栈TAG
+     * @return
+     */
+    private static String getCallerStackLogTag(){
+        return generateTag(getStackTraceElement(5));
+    }
+
+    /**
+     *
+     * @param t
+     * @return
+     */
+    private static String getStackTraceString(Throwable t){
+        return Log.getStackTraceString(t);
+    }
+
+    // -----------------------------------Log.v
+
+    /**
+     * Log.v
+     * @param msg
+     */
+    public static void v(String msg) {
+        if (isShowLog && priority <= VERBOSE)
+            Log.v(getCallerStackLogTag(), String.valueOf(msg));
+
+    }
+
+    public static void v(Throwable t) {
+        if (isShowLog && priority <= VERBOSE)
+            Log.v(getCallerStackLogTag(), getStackTraceString(t));
+    }
+
+    public static void v(String msg,Throwable t) {
+        if (isShowLog && priority <= VERBOSE)
+            Log.v(getCallerStackLogTag(), String.valueOf(msg), t);
+    }
+
+    // -----------------------------------Log.d
+
+    /**
+     * Log.d
+     * @param msg
+     */
+    public static void d(String msg) {
+        if (isShowLog && priority <= DEBUG)
+            Log.d(getCallerStackLogTag(), String.valueOf(msg));
+    }
+
+    public static void d(Throwable t) {
+        if (isShowLog && priority <= DEBUG)
+            Log.d(getCallerStackLogTag(), getStackTraceString(t));
+    }
+
+    public static void d(String msg,Throwable t) {
+        if (isShowLog && priority <= DEBUG)
+            Log.d(getCallerStackLogTag(), String.valueOf(msg), t);
+    }
+
+    // -----------------------------------Log.i
+
+    /**
+     * Log.i
+     * @param msg
+     */
+    public static void i(String msg) {
+        if (isShowLog && priority <= INFO)
+            Log.i(getCallerStackLogTag(), String.valueOf(msg));
+    }
+
+    public static void i(Throwable t) {
+        if (isShowLog && priority <= INFO)
+            Log.i(getCallerStackLogTag(), getStackTraceString(t));
+    }
+
+    public static void i(String msg,Throwable t) {
+        if (isShowLog && priority <= INFO)
+            Log.i(getCallerStackLogTag(), String.valueOf(msg), t);
+    }
+
+    // -----------------------------------Log.w
+
+    /**
+     * Log.w
+     * @param msg
+     */
+    public static void w(String msg) {
+        if (isShowLog && priority <= WARN)
+            Log.w(getCallerStackLogTag(), String.valueOf(msg));
+    }
+
+    public static void w(Throwable t) {
+        if (isShowLog && priority <= WARN)
+            Log.w(getCallerStackLogTag(), getStackTraceString(t));
+    }
+
+    public static void w(String msg,Throwable t) {
+        if (isShowLog && priority <= WARN)
+            Log.w(getCallerStackLogTag(), String.valueOf(msg), t);
+    }
+
+    // -----------------------------------Log.e
+
+    /**
+     * Log.e
+     * @param msg
+     */
+    public static void e(String msg) {
+        if (isShowLog && priority <= ERROR)
+            Log.e(getCallerStackLogTag(), String.valueOf(msg));
+    }
+
+    public static void e(Throwable t) {
+        if (isShowLog && priority <= ERROR)
+            Log.e(getCallerStackLogTag(), getStackTraceString(t));
+    }
+
+    public static void e(String msg,Throwable t) {
+        if (isShowLog && priority <= ERROR)
+            Log.e(getCallerStackLogTag(), String.valueOf(msg), t);
+    }
+
+    // -----------------------------------Log.wtf
+
+    /**
+     * Log.wtf
+     * @param msg
+     */
+    public static void wtf(String msg) {
+        if (isShowLog && priority <= ASSERT)
+            Log.wtf(getCallerStackLogTag(), String.valueOf(msg));
+    }
+
+    public static void wtf(Throwable t) {
+        if (isShowLog && priority <= ASSERT)
+            Log.wtf(getCallerStackLogTag(), getStackTraceString(t));
+    }
+
+    public static void wtf(String msg,Throwable t) {
+        if (isShowLog && priority <= ASSERT)
+            Log.wtf(getCallerStackLogTag(), String.valueOf(msg), t);
+    }
+
+    // -----------------------------------System.out.print
+
+    /**
+     * System.out.print
+     *
+     * @param msg
+     */
+    public static void print(String msg) {
+        if (isShowLog && priority <= PRINTLN)
+            System.out.print(msg);
+    }
+
+    public static void print(Object obj) {
+        if (isShowLog && priority <= PRINTLN)
+            System.out.print(obj);
+    }
+
+    // -----------------------------------System.out.printf
+
+    /**
+     * System.out.printf
+     *
+     * @param msg
+     */
+    public static void printf(String msg) {
+        if (isShowLog && priority <= PRINTLN)
+            System.out.printf(msg);
+    }
+
+    // -----------------------------------System.out.println
+
+    /**
+     * System.out.println
+     *
+     * @param msg
+     */
+    public static void println(String msg) {
+        if (isShowLog && priority <= PRINTLN)
+            System.out.println(msg);
+    }
+
+    public static void println(Object obj) {
+        if (isShowLog && priority <= PRINTLN)
+            System.out.println(obj);
+    }
+
+}

+ 276 - 0
home/src/main/java/com/yingyangfly/home/updater/util/NotificationUtils.java

@@ -0,0 +1,276 @@
+package com.yingyangfly.home.updater.util;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.RequiresApi;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+import com.yingyangfly.home.updater.UpdateConfig;
+import com.yingyangfly.home.updater.constant.Constants;
+import com.yingyangfly.home.updater.service.DownloadService;
+
+import java.io.File;
+
+/**
+ * 通知栏工具
+ *
+ * @author <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public class NotificationUtils {
+
+    private NotificationUtils() {
+        throw new AssertionError();
+    }
+
+    /**
+     * 显示开始下载时的通知
+     *
+     * @param notifyId
+     * @param channelId
+     * @param channelName
+     * @param smallIcon
+     * @param title
+     * @param content
+     */
+    public static void showStartNotification(Context context, int notifyId, String channelId, String channelName, @DrawableRes int smallIcon, CharSequence title, CharSequence content, boolean isVibrate, boolean isSound, boolean isSupportCancelDownload) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            createNotificationChannel(context, channelId, channelName, isVibrate, isSound);
+        }
+        NotificationCompat.Builder builder = buildNotification(context, channelId, smallIcon, title, content);
+        builder.setPriority(NotificationCompat.PRIORITY_DEFAULT);
+        if (isVibrate && isSound) {
+            builder.setDefaults(Notification.DEFAULT_VIBRATE | Notification.DEFAULT_SOUND);
+        } else if (isVibrate) {
+            builder.setDefaults(Notification.DEFAULT_VIBRATE);
+        } else if (isSound) {
+            builder.setDefaults(Notification.DEFAULT_SOUND);
+        }
+        // 如果支持取消下载,点击通知栏时,则取消下载
+        if (isSupportCancelDownload) {
+            Intent intent = new Intent(context, DownloadService.class);
+            intent.putExtra(Constants.KEY_STOP_DOWNLOAD_SERVICE, true);
+            PendingIntent deleteIntent = PendingIntent.getService(context, notifyId, intent, getPendingIntentFlags(PendingIntent.FLAG_CANCEL_CURRENT));
+            builder.setDeleteIntent(deleteIntent);
+        }
+
+        Notification notification = builder.build();
+        if (isSupportCancelDownload) {
+            notification.flags = Notification.FLAG_ONLY_ALERT_ONCE;
+        } else {
+            notification.flags = Notification.FLAG_NO_CLEAR | Notification.FLAG_ONLY_ALERT_ONCE;
+        }
+
+        notifyNotification(context, notifyId, notification);
+    }
+
+    /**
+     * 显示下载中的通知(更新进度)
+     *
+     * @param notifyId
+     * @param channelId
+     * @param smallIcon
+     * @param title
+     * @param content
+     * @param progress
+     * @param size
+     */
+    public static void showProgressNotification(Context context, int notifyId, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, int progress, int size, boolean isSupportCancelDownload) {
+        NotificationCompat.Builder builder = buildNotification(context, channelId, smallIcon, title, content, progress, size);
+        // 如果支持取消下载,点击通知栏时,则取消下载
+        if (isSupportCancelDownload) {
+            Intent intent = new Intent(context, DownloadService.class);
+            intent.putExtra(Constants.KEY_STOP_DOWNLOAD_SERVICE, true);
+            PendingIntent deleteIntent = PendingIntent.getService(context, notifyId, intent, getPendingIntentFlags(PendingIntent.FLAG_CANCEL_CURRENT));
+            builder.setDeleteIntent(deleteIntent);
+        }
+
+        Notification notification = builder.build();
+
+        if (isSupportCancelDownload) {
+            notification.flags = Notification.FLAG_ONLY_ALERT_ONCE;
+        } else {
+            notification.flags = Notification.FLAG_NO_CLEAR | Notification.FLAG_ONLY_ALERT_ONCE;
+        }
+
+        notifyNotification(context, notifyId, notification);
+    }
+
+    /**
+     * 显示下载完成时的通知(点击安装)
+     *
+     * @param notifyId
+     * @param channelId
+     * @param smallIcon
+     * @param title
+     * @param content
+     * @param file
+     */
+    public static void showFinishNotification(Context context, int notifyId, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, File file, String authority) {
+        cancelNotification(context, notifyId);
+        NotificationCompat.Builder builder = buildNotification(context, channelId, smallIcon, title, content);
+        builder.setAutoCancel(true);
+        Intent intent = AppUtils.getInstallIntent(context, file, authority);
+        PendingIntent clickIntent = PendingIntent.getActivity(context, notifyId, intent, getPendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT));
+        builder.setContentIntent(clickIntent);
+        Notification notification = builder.build();
+        notification.flags = Notification.FLAG_AUTO_CANCEL;
+        notifyNotification(context, notifyId, notification);
+    }
+
+    /**
+     * 现在下载失败通知
+     *
+     * @param context
+     * @param notifyId
+     * @param channelId
+     * @param smallIcon
+     * @param title
+     * @param content
+     * @param isReDownload
+     * @param config
+     */
+    public static void showErrorNotification(Context context, int notifyId, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, boolean isReDownload, UpdateConfig config) {
+        NotificationCompat.Builder builder = buildNotification(context, channelId, smallIcon, title, content);
+        builder.setAutoCancel(true);
+        int flag = getPendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT);
+        // 点击通知栏时,则重新下载
+        if (isReDownload) {
+            Intent intent = new Intent(context, DownloadService.class);
+            intent.putExtra(Constants.KEY_RE_DOWNLOAD, true);
+            intent.putExtra(Constants.KEY_UPDATE_CONFIG, config);
+            PendingIntent clickIntent = PendingIntent.getService(context, notifyId, intent, flag);
+            builder.setContentIntent(clickIntent);
+        } else {
+            // 点击通知栏时,则自动取消通知栏
+            PendingIntent clickIntent = PendingIntent.getService(context, notifyId, new Intent(), flag);
+            builder.setContentIntent(clickIntent);
+        }
+
+        Notification notification = builder.build();
+        notification.flags = Notification.FLAG_AUTO_CANCEL;
+        notifyNotification(context, notifyId, notification);
+    }
+
+
+    /**
+     * 显示通知信息(非第一次)
+     *
+     * @param notifyId
+     * @param channelId
+     * @param smallIcon
+     * @param title
+     * @param content
+     */
+    public static void showNotification(Context context, int notifyId, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, boolean isAutoCancel) {
+        NotificationCompat.Builder builder = buildNotification(context, channelId, smallIcon, title, content);
+        builder.setAutoCancel(isAutoCancel);
+        Notification notification = builder.build();
+        notification.flags = Notification.FLAG_AUTO_CANCEL;
+        notifyNotification(context, notifyId, notification);
+    }
+
+    /**
+     * 取消通知
+     *
+     * @param notifyId
+     */
+    public static void cancelNotification(Context context, int notifyId) {
+        getNotificationManager(context).cancel(notifyId);
+    }
+
+
+    /**
+     * 获取通知管理器
+     *
+     * @return {@link NotificationManagerCompat}
+     */
+    public static NotificationManagerCompat getNotificationManager(Context context) {
+        return NotificationManagerCompat.from(context);
+    }
+
+    /**
+     * 创建一个通知通道(兼容0以上版本)
+     *
+     * @param channelId
+     * @param channelName
+     */
+    @RequiresApi(api = Build.VERSION_CODES.O)
+    public static void createNotificationChannel(Context context, String channelId, String channelName, boolean isVibrate, boolean isSound) {
+        NotificationChannel channel = new NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH);
+        channel.setShowBadge(true);
+        channel.enableVibration(isVibrate);
+        if (!isSound) {
+            channel.setSound(null, null);
+        }
+        getNotificationManager(context).createNotificationChannel(channel);
+
+    }
+
+    /**
+     * 构建一个通知构建器
+     *
+     * @param channelId
+     * @param smallIcon
+     * @param title
+     * @param content
+     * @return
+     */
+    private static NotificationCompat.Builder buildNotification(Context context, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content) {
+        return buildNotification(context, channelId, smallIcon, title, content, Constants.NONE, Constants.NONE);
+    }
+
+    /**
+     * 构建一个通知构建器
+     *
+     * @param channelId
+     * @param smallIcon
+     * @param title
+     * @param content
+     * @param progress
+     * @param size
+     * @return
+     */
+    private static NotificationCompat.Builder buildNotification(Context context, String channelId, @DrawableRes int smallIcon, CharSequence title, CharSequence content, int progress, int size) {
+        NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);
+        builder.setSmallIcon(smallIcon);
+        builder.setContentTitle(title);
+        builder.setContentText(content);
+        builder.setOngoing(true);
+
+        if (progress != Constants.NONE) {
+            builder.setProgress(size, progress, size <= 0);
+        }
+        return builder;
+    }
+
+    /**
+     * 更新通知栏
+     *
+     * @param id
+     * @param notification
+     */
+    private static void notifyNotification(Context context, int id, Notification notification) {
+        getNotificationManager(context).notify(id, notification);
+    }
+
+    /**
+     * 获取 PendingIntent 的 flags
+     *
+     * @param flag
+     * @return
+     */
+    private static int getPendingIntentFlags(int flag) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            return flag | PendingIntent.FLAG_IMMUTABLE;
+        }
+        return flag;
+    }
+}

+ 65 - 0
home/src/main/java/com/yingyangfly/home/updater/util/PermissionUtils.java

@@ -0,0 +1,65 @@
+package com.yingyangfly.home.updater.util;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.ActivityCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+/**
+ * 权限工具
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public final class PermissionUtils {
+
+    private PermissionUtils() {
+        throw new AssertionError();
+    }
+
+    /**
+     * 校验权限
+     *
+     * @param activity
+     * @param requestCode
+     * @return
+     */
+    public static boolean verifyReadAndWritePermissions(@NonNull Activity activity, int requestCode) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            int readResult = checkPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE);
+            int writeResult = checkPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
+            if (readResult != PackageManager.PERMISSION_GRANTED || writeResult != PackageManager.PERMISSION_GRANTED) {
+                ActivityCompat.requestPermissions(activity, new String[]{
+                        Manifest.permission.READ_EXTERNAL_STORAGE,
+                        Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode);
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 检查权限
+     *
+     * @param context
+     * @param permission
+     * @return
+     */
+    public static int checkPermission(@NonNull Context context, @NonNull String permission) {
+        return ActivityCompat.checkSelfPermission(context, permission);
+    }
+
+    /**
+     * 通知栏是否启用
+     *
+     * @param context
+     */
+    public static boolean isNotificationEnabled(Context context) {
+        return NotificationManagerCompat.from(context).areNotificationsEnabled();
+    }
+
+}

+ 120 - 0
home/src/main/java/com/yingyangfly/home/updater/util/SSLSocketFactoryUtils.java

@@ -0,0 +1,120 @@
+package com.yingyangfly.home.updater.util;
+
+import android.annotation.SuppressLint;
+
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+
+/**
+ * SSLSocketFactory 工具
+ *
+ * @author Jenly <a href="mailto:jenly1314@gmail.com">Jenly</a>
+ */
+public final class SSLSocketFactoryUtils {
+
+    private SSLSocketFactoryUtils() {
+        throw new AssertionError();
+    }
+
+    /**
+     * 创建 SSLSocketFactory
+     *
+     * @return {@link SSLSocketFactory}
+     */
+    public static SSLSocketFactory createSSLSocketFactory() {
+        try {
+            SSLContext sslContext = SSLContext.getInstance("TLS");
+            sslContext.init(null, new TrustManager[]{new TrustAllX509TrustManager(true, null)}, null);
+            return sslContext.getSocketFactory();
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * 信任所有 X509TrustManager
+     *
+     * @return {@link X509TrustManager}
+     */
+    @SuppressLint("CustomX509TrustManager")
+    public static class TrustAllX509TrustManager implements X509TrustManager {
+        private X509TrustManager standardTrustManager;
+        private boolean isTrustAll;
+
+        public TrustAllX509TrustManager(Boolean trustAll, KeyStore keystore) {
+            this.isTrustAll = trustAll;
+            try {
+                TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+                factory.init(keystore);
+                TrustManager[] trustManagers = factory.getTrustManagers();
+                if (trustManagers.length == 0 || !(trustManagers[0] instanceof X509TrustManager)) {
+                    throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers));
+                }
+                this.standardTrustManager = (X509TrustManager) trustManagers[0];
+            } catch (NoSuchAlgorithmException e) {
+                e.printStackTrace();
+            } catch (KeyStoreException e) {
+                e.printStackTrace();
+            }
+        }
+
+        @Override
+        public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+            if (!isTrustAll) {
+                standardTrustManager.checkClientTrusted(chain, authType);
+            }
+        }
+
+        @Override
+        public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+            if (!isTrustAll) {
+                if (chain != null && chain.length == 1) {
+                    chain[0].checkValidity();
+                } else {
+                    standardTrustManager.checkServerTrusted(chain, authType);
+                }
+            }
+        }
+
+        @Override
+        public X509Certificate[] getAcceptedIssuers() {
+            return standardTrustManager.getAcceptedIssuers();
+        }
+    }
+
+    /**
+     * 创建一个信任所有证书的 X509TrustManager
+     *
+     * @return {@link X509TrustManager}
+     */
+    public static X509TrustManager createTrustAllX509TrustManager() {
+        return new TrustAllX509TrustManager(true, null);
+    }
+
+    /**
+     * 创建一个忽略校验信任所有主机地址的 HostnameVerifier
+     *
+     * @return {@link HostnameVerifier}
+     */
+    public static HostnameVerifier createAllowAllHostnameVerifier() {
+        return new HostnameVerifier() {
+            @Override
+            public boolean verify(String hostname, SSLSession session) {
+                return hostname != null;
+            }
+        };
+    }
+
+}

+ 12 - 0
home/src/main/manifest/AndroidManifest.xml

@@ -14,6 +14,18 @@
             android:screenOrientation="landscape"
             android:windowSoftInputMode="adjustResize|adjustPan" />
 
+        <service android:name="com.yingyangfly.home.updater.service.DownloadService"
+            android:exported="false"/>
+
+        <provider
+            android:name="com.yingyangfly.home.updater.provider.AppUpdaterFileProvider"
+            android:authorities="${applicationId}.AppUpdaterFileProvider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/app_updater_paths"/>
+        </provider>
     </application>
 
 </manifest>

+ 10 - 0
home/src/main/res/values/strings.xml

@@ -1,4 +1,5 @@
 <resources xmlns:tools="http://schemas.android.com/tools">
+    <string name="app_name" tools:ignore="ResourceName">FutureBluePorpoiseCognitivePlatform</string>
     <string name="home_pager" tools:ignore="ExtraTranslation,ResourceName">首页</string>
     <string name="live_broadcast" tools:ignore="ResourceName">直播</string>
     <string name="help" tools:ignore="ResourceName">使用帮助</string>
@@ -23,4 +24,13 @@
     <string name="next_page" tools:ignore="ResourceName">下一页</string>
     <string name="previous_page" tools:ignore="ResourceName">上一页</string>
     <string name="rehabilitation_classroom" tools:ignore="ResourceName">康复课堂</string>
+    <string name="app_updater_start_notification_title" tools:ignore="ResourceName">版本更新</string>
+    <string name="app_updater_start_notification_content" tools:ignore="ResourceName">正在获取下载数据…</string>
+    <string name="app_updater_progress_notification_title" tools:ignore="ResourceName">版本更新</string>
+    <string name="app_updater_progress_notification_content" tools:ignore="ResourceName">正在下载…</string>
+    <string name="app_updater_finish_notification_title" tools:ignore="ResourceName">下载完成</string>
+    <string name="app_updater_finish_notification_content" tools:ignore="ResourceName">点击安装</string>
+    <string name="app_updater_error_notification_title" tools:ignore="ResourceName">下载失败</string>
+    <string name="app_updater_error_notification_content" tools:ignore="ResourceName">点击关闭通知</string>
+    <string name="app_updater_error_notification_content_re_download" tools:ignore="ResourceName">点击重新下载</string>
 </resources>

+ 10 - 0
home/src/main/res/xml/app_updater_paths.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+    <root-path name="app_root_path" path="/"/>
+    <external-path name="app_external_path" path="/"/>
+    <external-cache-path name="app_external_cache_path" path="/"/>
+    <external-files-path name="app_external_files_path" path="/"/>
+    <files-path name="app_files_path" path="/"/>
+    <cache-path name="app_cache_path" path="/"/>
+
+</paths>