diff --git a/.gitignore b/.gitignore
index a0f25db..3f66ac5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -126,8 +126,8 @@ gradle.properties
#gradle.properties-android-demo
#gradle.properties-androidx-demo
#gradlew
-libaes
-libappbase
+#libaes
+#libappbase
libdebugtemp
libgpsrelaysentinel
#libwinboll
diff --git a/libaes/.gitignore b/libaes/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/libaes/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/libaes/build.gradle b/libaes/build.gradle
new file mode 100644
index 0000000..d6c3ad4
--- /dev/null
+++ b/libaes/build.gradle
@@ -0,0 +1,69 @@
+apply plugin: 'com.android.library'
+apply plugin: 'maven-publish'
+apply from: '../.winboll/winboll_lib_build.gradle'
+apply from: '../.winboll/winboll_lint_build.gradle'
+
+android {
+ // 适配MIUI12
+ compileSdkVersion 30
+ buildToolsVersion "30.0.3"
+
+ defaultConfig {
+ minSdkVersion 26
+ targetSdkVersion 30
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ // 米盟 SDK
+ packagingOptions {
+ doNotStrip "*/*/libmimo_1011.so"
+ }
+}
+
+dependencies {
+ // 下拉控件
+ api 'com.baoyz.pullrefreshlayout:library:1.2.0'
+ // 拼音搜索
+ // https://mvnrepository.com/artifact/com.github.open-android/pinyin4j
+ api 'com.github.open-android:pinyin4j:2.5.0'
+ // SSH
+ api 'com.jcraft:jsch:0.1.55'
+ // Html 解析
+ api 'org.jsoup:jsoup:1.13.1'
+ // 二维码类库
+ api 'com.google.zxing:core:3.4.1'
+ api 'com.journeyapps:zxing-android-embedded:3.6.0'
+ // 应用介绍页类库
+ api 'io.github.medyo:android-about-page:2.0.0'
+ // 网络连接类库
+ api 'com.squareup.okhttp3:okhttp:4.4.1'
+ // AndroidX 类库
+ api 'androidx.appcompat:appcompat:1.1.0'
+ //api 'com.google.android.material:material:1.4.0'
+ //api 'androidx.viewpager:viewpager:1.0.0'
+ //api 'androidx.vectordrawable:vectordrawable:1.1.0'
+ //api 'androidx.vectordrawable:vectordrawable-animated:1.1.0'
+ //api 'androidx.fragment:fragment:1.1.0'
+
+ // 米盟
+ api 'com.miui.zeus:mimo-ad-sdk:5.3.+'//请使用最新版sdk
+ //注意:以下5个库必须要引入
+ //implementation 'androidx.appcompat:appcompat:1.4.1'
+ api 'androidx.recyclerview:recyclerview:1.0.0'
+ api 'com.google.code.gson:gson:2.8.5'
+ api 'com.github.bumptech.glide:glide:4.9.0'
+ //annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'
+
+ // WinBoLL库 nexus.winboll.cc 地址
+ api 'cc.winboll.studio:libappbase:15.20.9'
+ // 备用库 jitpack.io 地址
+ //api 'com.github.ZhanGSKen:APPBase:appbase-v15.15.3'
+
+ api fileTree(dir: 'libs', include: ['*.jar'])
+}
diff --git a/libaes/build.properties b/libaes/build.properties
new file mode 100644
index 0000000..8f4366b
--- /dev/null
+++ b/libaes/build.properties
@@ -0,0 +1,8 @@
+#Created by .winboll/winboll_app_build.gradle
+#Tue May 12 13:11:09 HKT 2026
+stageCount=4
+libraryProject=libaes
+baseVersion=15.20
+publishVersion=15.20.3
+buildCount=0
+baseBetaVersion=15.20.4
diff --git a/libaes/libs/colorpicker-20180319.jar b/libaes/libs/colorpicker-20180319.jar
new file mode 100644
index 0000000..b010a12
Binary files /dev/null and b/libaes/libs/colorpicker-20180319.jar differ
diff --git a/libaes/proguard-rules.pro b/libaes/proguard-rules.pro
new file mode 100644
index 0000000..536058a
--- /dev/null
+++ b/libaes/proguard-rules.pro
@@ -0,0 +1,17 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in C:/tools/adt-bundle-windows-x86_64-20131030/sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
diff --git a/libaes/src/main/AndroidManifest.xml b/libaes/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d4325dc
--- /dev/null
+++ b/libaes/src/main/AndroidManifest.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/DrawerMenuDataAdapter.java b/libaes/src/main/java/cc/winboll/studio/libaes/DrawerMenuDataAdapter.java
new file mode 100644
index 0000000..a9e7bf6
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/DrawerMenuDataAdapter.java
@@ -0,0 +1,207 @@
+package cc.winboll.studio.libaes;
+
+import android.content.Context;
+import android.util.SparseArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+import java.util.ArrayList;
+
+public abstract class DrawerMenuDataAdapter extends BaseAdapter {
+
+ private ArrayList mData;
+ private int mLayoutResource; //布局id
+
+
+ public DrawerMenuDataAdapter() {
+ }
+
+ public DrawerMenuDataAdapter(ArrayList mData, int mLayoutRes) {
+ this.mData = mData;
+ this.mLayoutResource = mLayoutRes;
+ }
+
+ @Override
+ public int getCount() {
+ return mData != null ? mData.size() : 0;
+ }
+
+ @Override
+ public T getItem(int position) {
+ return mData.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ ViewHolder viewHolder = ViewHolder.bind(parent.getContext(), convertView, parent, mLayoutResource
+ , position);
+ bindView(viewHolder, getItem(position));
+ return viewHolder.getItemView();
+ }
+
+ public abstract void bindView(ViewHolder holder, T obj);
+
+ // 添加数据项
+ //
+ public void add(T item) {
+ if (mData == null) {
+ mData = new ArrayList<>();
+ }
+ mData.add(item);
+ notifyDataSetChanged();
+ }
+
+ // 添加数据项在指定位置
+ //
+ public void add(int position, T item) {
+ if (mData == null) {
+ mData = new ArrayList<>();
+ }
+ mData.add(position, item);
+ notifyDataSetChanged();
+ }
+
+ // 删除数据项
+ //
+ public void remove(T item) {
+ if (mData != null) {
+ mData.remove(item);
+ }
+ notifyDataSetChanged();
+ }
+
+ // 删除指定位置数据项
+ //
+ public void remove(int position) {
+ if (mData != null) {
+ mData.remove(position);
+ }
+ notifyDataSetChanged();
+ }
+
+ // 清理所有数据项
+ //
+ public void clear() {
+ if (mData != null) {
+ mData.clear();
+ }
+ notifyDataSetChanged();
+ }
+
+
+ public static class ViewHolder {
+
+ // 存储在 ListView 的 item 中的 View
+ SparseArray mSparseArrayView;
+ // 存放convertView
+ View mViewItem;
+ // 游标
+ int mnPosition;
+ Context mContext;
+
+ //构造方法
+ //
+ private ViewHolder(Context context, ViewGroup parent, int layoutResource) {
+ mSparseArrayView = new SparseArray<>();
+ this.mContext = context;
+ View convertView = LayoutInflater.from(context).inflate(layoutResource, parent, false);
+ convertView.setTag(this);
+ mViewItem = convertView;
+ }
+
+ //绑定 ViewHolder 与数据项
+ //
+ public static ViewHolder bind(Context context, View convertView, ViewGroup parent,
+ int layoutResource, int position) {
+ ViewHolder viewHolder;
+ if (convertView == null) {
+ viewHolder = new ViewHolder(context, parent, layoutResource);
+ } else {
+ viewHolder = (ViewHolder) convertView.getTag();
+ viewHolder.mViewItem = convertView;
+ }
+ viewHolder.mnPosition = position;
+ return viewHolder;
+ }
+
+ @SuppressWarnings("unchecked")
+ public T getView(int id) {
+ T t = (T) mSparseArrayView.get(id);
+ if (t == null) {
+ t = (T) mViewItem.findViewById(id);
+ mSparseArrayView.put(id, t);
+ }
+ return t;
+ }
+
+
+
+ // 获取当前条目
+ //
+ public View getItemView() {
+ return mViewItem;
+ }
+
+
+ // 获取条目位置
+ //
+ public int getItemPosition() {
+ return mnPosition;
+ }
+
+
+ // 设置文字
+ //
+ public ViewHolder setText(int id, CharSequence text) {
+ View view = getView(id);
+ if (view instanceof TextView) {
+ ((TextView) view).setText(text);
+ }
+ return this;
+ }
+
+
+ // 设置图片
+ //
+ public ViewHolder setImageResource(int id, int drawableResource) {
+ View view = getView(id);
+ if (view instanceof ImageView) {
+ ((ImageView) view).setImageResource(drawableResource);
+ } else {
+ view.setBackgroundResource(drawableResource);
+ }
+ return this;
+ }
+
+ // 设置点击监听
+ //
+ public ViewHolder setOnClickListener(int id, View.OnClickListener listener) {
+ getView(id).setOnClickListener(listener);
+ return this;
+ }
+
+ // 设置可见
+ //
+ public ViewHolder setVisibility(int id, int visible) {
+ getView(id).setVisibility(visible);
+ return this;
+ }
+
+ // 设置标签
+ //
+ public ViewHolder setTag(int id, Object obj) {
+ getView(id).setTag(obj);
+ return this;
+ }
+
+ }
+
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/ImagePagerAdapter.java b/libaes/src/main/java/cc/winboll/studio/libaes/ImagePagerAdapter.java
new file mode 100644
index 0000000..488eb8f
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/ImagePagerAdapter.java
@@ -0,0 +1,75 @@
+package cc.winboll.studio.libaes;
+
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.viewpager.widget.PagerAdapter;
+import java.util.List;
+
+public class ImagePagerAdapter extends PagerAdapter {
+
+ /*
+ * 四个必须重写的方法,否则会报错
+ *
+ */
+
+ private List views;
+
+ //构造方法,拿到views
+ public ImagePagerAdapter(List views) {
+ this.views = views;
+ }
+
+ //以下四个是重写的方法
+ // 获取要滑动的控件的数量,在这里我们以滑动的广告栏为例,那么这里就应该是展示的广告图片的ImageView数量
+ @Override
+ public int getCount() {
+ // TODO Auto-generated method stub
+ return this.views.size();
+ }
+
+
+ // 来判断显示的是否是同一张图片,这里我们将两个参数相比较返回即可
+ @Override
+ public boolean isViewFromObject(View arg0, Object arg1) {
+ // TODO Auto-generated method stub
+ return arg0 == arg1;
+ }
+
+
+ /**
+ * position是在viewPager中显示图片的下标
+ * 把对应的图片放到对应的位置就好了
+ * instantiateItem和destroyItem是对应的
+ * 一个是创建item,一个是销毁item
+ * 当要显示的图片可以进行缓存的时候,会调用instantiateItem()进行显示图片的初始化,
+ * 我们将要显示的ImageView加入到ViewGroup中,然后作为返回值返回即可
+ *
+ * ViewPager 是扩展于 ViewGroup,container参数是当前的ViewPager对象,
+ * 所有的item都会被加入到ViewPager中,
+ * position就是 每个item对应的下标
+ */
+ @Override
+ public Object instantiateItem(ViewGroup container, int position) {
+ container.addView(views.get(position));
+ return views.get(position);
+ }
+
+ //如果出现IllegalStateException: The specified child already has a parent. 这样的错误:则可替换为以下的try catch 代码
+ /*try{
+ if(views.get(position).getParent()==null){
+ container.addView(views.get(position));
+ }else{
+ ((ViewGroup)views.get(position).getParent()).removeView(views.get(position));
+ container.addView(views.get(position));
+ }
+ }catch(Exception e){
+ e.printStackTrace();
+ }*/
+
+ // PagerAdapter只缓存5张要显示的图片,如果滑动的图片超出了缓存的范围,就会调用destroyItem(),将图片销毁
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object object) {
+ container.removeView(views.get(position));
+ }
+}
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/activitys/DrawerFragmentActivity.java b/libaes/src/main/java/cc/winboll/studio/libaes/activitys/DrawerFragmentActivity.java
new file mode 100644
index 0000000..97610a3
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/activitys/DrawerFragmentActivity.java
@@ -0,0 +1,394 @@
+package cc.winboll.studio.libaes.activitys;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/06/13 18:58:54
+ * @Describe 可以加入Fragment的有抽屉的活动窗口抽象类
+ */
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import androidx.appcompat.app.ActionBarDrawerToggle;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentTransaction;
+import cc.winboll.studio.libaes.DrawerMenuDataAdapter;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.models.AESThemeBean;
+import cc.winboll.studio.libaes.models.DrawerMenuBean;
+import cc.winboll.studio.libaes.utils.AESThemeUtil;
+import cc.winboll.studio.libaes.utils.DevelopUtils;
+import cc.winboll.studio.libaes.utils.WinBoLLActivityManager;
+import cc.winboll.studio.libaes.views.ADrawerMenuListView;
+import cc.winboll.studio.libaes.views.ADsBannerView;
+import cc.winboll.studio.libappbase.GlobalApplication;
+import cc.winboll.studio.libappbase.LogUtils;
+import com.baoyz.widget.PullRefreshLayout;
+import java.util.ArrayList;
+import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
+
+public abstract class DrawerFragmentActivity extends AppCompatActivity implements IWinBoLLActivity, AdapterView.OnItemClickListener {
+
+ public static final String TAG = "DrawerFragmentActivity";
+
+ static final String SHAREDPREFERENCES_NAME = "SHAREDPREFERENCES_NAME";
+ static final String DRAWER_THEME_TYPE = "DRAWER_THEME_TYPE";
+
+ //protected Context mContext;
+ ActivityType mActivityType;
+ ActionBarDrawerToggle mActionBarDrawerToggle;
+ DrawerLayout mDrawerLayout;
+ PullRefreshLayout mPullRefreshLayout;
+ ADrawerMenuListView mADrawerMenuListView;
+ DrawerMenuDataAdapter mDrawerMenuDataAdapter;
+ boolean mIsDrawerOpened = false;
+ boolean mIsDrawerOpening = false;
+ boolean mIsDrawerClosing = false;
+
+ protected Toolbar mToolbar;
+ public enum ActivityType { Main, Secondary }
+ protected volatile AESThemeBean.ThemeType mThemeType;
+ protected ArrayList malDrawerMenuItem;
+ abstract protected ActivityType initActivityType();
+ //abstract protected View initContentView(LayoutInflater inflater, ViewGroup rootView);
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ mThemeType = AESThemeBean.getThemeStyleType(AESThemeUtil.getThemeTypeID(getApplicationContext()));
+ setTheme(AESThemeUtil.getThemeTypeID(getApplicationContext()));
+ super.onCreate(savedInstanceState);
+ WinBoLLActivityManager.getInstance().add(this);
+ mActivityType = initActivityType();
+ initRootView();
+ LogUtils.d(TAG, "onCreate end.");
+ }
+
+ @Override
+ public Activity getActivity() {
+ return this;
+ }
+
+ @Override
+ public String getTag() {
+ return TAG;
+ }
+
+ @Override
+ protected void onDestroy() {
+ WinBoLLActivityManager.getInstance().registeRemove(this);
+ super.onDestroy();
+ // 修复:释放广告资源,避免内存泄漏
+ ADsBannerView adsBannerView = findViewById(R.id.adsbanner);
+ if (adsBannerView != null) {
+ adsBannerView.releaseAdResources();
+ }
+ }
+
+ /*@Override
+ public Intent getIntent() {
+ // TODO: Implement this method
+ return super.getIntent();
+ }
+
+ public Context getContext() {
+ return this.mContext;
+ }*/
+
+ @Override
+ public MenuInflater getMenuInflater() {
+ // TODO: Implement this method
+ return super.getMenuInflater();
+ }
+
+ /*public void setSubtitle(CharSequence context) {
+ // TODO: Implement this method
+ getSupportActionBar().setSubtitle(context);
+ }*/
+
+ @Override
+ public void recreate() {
+ super.recreate();
+ }
+
+ /*@Override
+ public boolean moveTaskToBack(boolean nonRoot) {
+ return super.moveTaskToBack(nonRoot);
+ }*/
+
+ @Override
+ public void startActivity(Intent intent) {
+ super.startActivity(intent);
+ }
+
+ @Override
+ public void startActivityForResult(Intent intent, int requestCode, Bundle options) {
+ super.startActivityForResult(intent, requestCode, options);
+ }
+
+ /*@Override
+ public FragmentManager getSupportFragmentManager() {
+ return super.getSupportFragmentManager();
+ }
+
+ public void setSubtitle(int resId) {
+ // TODO: Implement this method
+ getSupportActionBar().setSubtitle(resId);
+ }
+
+ public void setTitle(CharSequence context) {
+ // TODO: Implement this method
+ getSupportActionBar().setTitle(context);
+ }
+
+ public void setTitle(int resId) {
+ // TODO: Implement this method
+ getSupportActionBar().setTitle(resId);
+ }*/
+
+ @Override
+ public SharedPreferences getSharedPreferences(String name, int mode) {
+ return super.getSharedPreferences(name, mode);
+ }
+
+ @Override
+ public Context getApplicationContext() {
+ // TODO: Implement this method
+ return super.getApplicationContext();
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (AESThemeUtil.onAppThemeItemSelected(this, item)) {
+ recreate();
+ } if (DevelopUtils.onDevelopItemSelected(this, item)) {
+ LogUtils.d(TAG, String.format("onOptionsItemSelected item.getItemId() %d ", item.getItemId()));
+ } else {
+ return super.onOptionsItemSelected(item);
+ }
+
+ return true;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ ADsBannerView adsBannerView = findViewById(R.id.adsbanner);
+ if (adsBannerView != null) {
+ adsBannerView.resumeADs(DrawerFragmentActivity.this);
+ }
+ }
+
+ void initRootView() {
+ setContentView(R.layout.activity_drawerfragment);
+
+ mToolbar = findViewById(R.id.activitydrawerfragmentASupportToolbar1);
+ setSupportActionBar(mToolbar);
+
+ if (mActivityType == ActivityType.Main) {
+ initMainRootView();
+ } else if (mActivityType == ActivityType.Secondary) {
+ initSecondaryRootView();
+ }
+ }
+
+ void initMainRootView() {
+ mDrawerLayout = findViewById(R.id.activitydrawerfragmentDrawerLayout1);
+ mADrawerMenuListView = findViewById(R.id.activitydrawerfragmentDrawerMenuListView1);
+ mPullRefreshLayout = findViewById(R.id.activitydrawerfragmentPullRefreshLayout1);
+
+ mPullRefreshLayout.setOnRefreshListener(new PullRefreshLayout.OnRefreshListener() {
+ @Override
+ public void onRefresh() {
+ //LogUtils.d(TAG, "onRefresh");
+ reinitDrawerMenuItemList(malDrawerMenuItem);
+ mDrawerMenuDataAdapter.notifyDataSetChanged();
+ mPullRefreshLayout.setRefreshing(false);
+ }
+ });
+
+ malDrawerMenuItem = new ArrayList();
+
+ mDrawerMenuDataAdapter = new DrawerMenuDataAdapter(malDrawerMenuItem, R.layout.listview_drawermenu) {
+ @Override
+ public void bindView(ViewHolder holder, DrawerMenuBean obj) {
+ holder.setImageResource(R.id.listviewdrawermenuImageView1, obj.getIconId());
+ holder.setText(R.id.listviewdrawermenuTextView1, obj.getIconName());
+ }
+ };
+ mADrawerMenuListView.setAdapter(mDrawerMenuDataAdapter);
+ mADrawerMenuListView.setOnItemClickListener(this);
+
+ mActionBarDrawerToggle = new ActionBarDrawerToggle(this, mDrawerLayout, mToolbar, R.string.lib_name, R.string.lib_name) {
+ @Override
+ public void onDrawerOpened(View drawerView) {//完全打开时触发
+ super.onDrawerOpened(drawerView);
+ mIsDrawerOpened = true;
+ mIsDrawerOpening = false;
+ //Toast.makeText(MainActivity.this,"onDrawerOpened",Toast.LENGTH_SHORT).show();
+ }
+
+ @Override
+ public void onDrawerClosed(View drawerView) {//完全关闭时触发
+ super.onDrawerClosed(drawerView);
+ mIsDrawerOpened = false;
+ mIsDrawerClosing = false;
+ //Toast.makeText(MainActivity.this,"onDrawerClosed",Toast.LENGTH_SHORT).show();
+ }
+
+ /**
+ * 当抽屉被滑动的时候调用此方法
+ * slideOffset表示 滑动的幅度(0-1)
+ */
+ @Override
+ public void onDrawerSlide(View drawerView, float slideOffset) {
+ super.onDrawerSlide(drawerView, slideOffset);
+ }
+
+ /**
+ * 当抽屉滑动状态改变的时候被调用
+ * 状态值是STATE_IDLE(闲置--0), STATE_DRAGGING(拖拽的--1), STATE_SETTLING(固定--2)中之一。
+ *具体状态可以慢慢调试
+ */
+ @Override
+ public void onDrawerStateChanged(int newState) {
+ super.onDrawerStateChanged(newState);
+ }
+ };
+
+ //设置显示旋转菜单
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ //通过下面这句实现toolbar和Drawer的联动:如果没有这行代码,箭头是不会随着侧滑菜单的开关而变换的(或者没有箭头),
+ // 可以尝试一下,不影响正常侧滑
+ mActionBarDrawerToggle.syncState();
+ mDrawerLayout.setDrawerListener(mActionBarDrawerToggle);
+
+ //去掉侧滑的默认图标(动画箭头图标),也可以选择不去,
+ //不去的话把这一行注释掉或者改成true,然后把toolbar.setNavigationIcon注释掉就行了
+ //mActionBarDrawerToggle.setDrawerIndicatorEnabled(false);
+ mToolbar.setNavigationOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mIsDrawerOpened || mIsDrawerOpening) {
+ mIsDrawerClosing = true;
+ mIsDrawerOpening = false;
+ mDrawerLayout.closeDrawer(mPullRefreshLayout);
+ return;
+ }
+ if (!mIsDrawerOpened || mIsDrawerClosing) {
+ mIsDrawerOpening = true;
+ mIsDrawerClosing = false;
+ mDrawerLayout.openDrawer(mPullRefreshLayout);
+ return;
+ }
+ }
+ });
+
+ initDrawerMenuItemList(malDrawerMenuItem);
+ }
+
+ void initSecondaryRootView() {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ mToolbar.setNavigationOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ //LogUtils.d(TAG, "onClick " + Integer.toString(v.getId()));
+ finish();
+ }
+ });
+ }
+
+ public int removeFragment(T fragment) {
+ FragmentManager fragmentManager = getSupportFragmentManager();
+ FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
+ fragmentTransaction.remove(fragment);
+ fragmentTransaction.commit();
+ return fragmentManager.getFragments().size() - 1;
+ }
+
+ public int addFragment(T fragment) {
+ FragmentManager fragmentManager = getSupportFragmentManager();
+ FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
+ fragmentTransaction.add(R.id.activitydrawerfragmentFrameLayout1, fragment);
+ fragmentTransaction.commit();
+ return fragmentManager.getFragments().size() - 1;
+ }
+
+ public void showFragment(T fragment) {
+ FragmentManager fragmentManager = getSupportFragmentManager();
+ FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
+ for (int i = 0; i < fragmentManager.getFragments().size(); i++) {
+ if (fragmentManager.getFragments().get(i).equals(fragment)) {
+ fragmentTransaction.show(fragmentManager.getFragments().get(i));
+ } else {
+ fragmentTransaction.hide(fragmentManager.getFragments().get(i));
+ }
+ }
+ fragmentTransaction.commit();
+ }
+
+ public void showFragment(int nPosition) {
+ FragmentManager fragmentManager = getSupportFragmentManager();
+ FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
+ for (int i = 0; i < fragmentManager.getFragments().size(); i++) {
+ if (i == nPosition) {
+ fragmentTransaction.show(fragmentManager.getFragments().get(i));
+ } else {
+ fragmentTransaction.hide(fragmentManager.getFragments().get(i));
+ }
+ }
+ fragmentTransaction.commit();
+ }
+
+ protected void initDrawerMenuItemList(ArrayList listDrawerMenu) {
+
+ }
+
+ protected void reinitDrawerMenuItemList(ArrayList listDrawerMenu) {
+
+ }
+
+ public void notifyDrawerMenuDataChanged() {
+ mDrawerMenuDataAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ mDrawerLayout.closeDrawer(mPullRefreshLayout);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ if (mActivityType == ActivityType.Main) {
+ // 主题菜单
+ AESThemeUtil.inflateMenu(this, menu);
+ // 调试工具菜单
+ if (GlobalApplication.isDebugging()) {
+ DevelopUtils.inflateMenu(this, menu);
+ }
+ // 应用信息菜单
+ getMenuInflater().inflate(R.menu.toolbar_drawerbase, menu);
+ }
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ protected void onActivityResult(int who, int targetFragment, Intent requestCode) {
+ super.onActivityResult(who, targetFragment, requestCode);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/LocalFileSelectDialog.java b/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/LocalFileSelectDialog.java
new file mode 100644
index 0000000..c181865
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/LocalFileSelectDialog.java
@@ -0,0 +1,204 @@
+package cc.winboll.studio.libaes.dialogs;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.appcompat.app.AlertDialog;
+import cc.winboll.studio.libappbase.LogUtils;
+import java.io.File;
+import java.lang.reflect.Field;
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+
+public class LocalFileSelectDialog {
+
+ public static final String TAG = LocalFileSelectDialog.class.getSimpleName();
+
+ File mfCurrentPath = new File("/storage/emulated/0");
+ String mszResultPath = "/storage/emulated/0";
+ OKClickListener mOKClickListener;
+
+ Context mContext;
+
+ public LocalFileSelectDialog(Context context) {
+ mContext = context;
+ }
+
+ public void open() {
+ LogUtils.d(TAG, "call open()");
+ String[] szlist = getChildFileList(mfCurrentPath);
+ if (szlist != null) {
+ showSingleChoiceDialog(szlist, 0);
+ }
+ }
+
+ int yourChoice;
+ protected void showSingleChoiceDialog(final String[] szItems, final int nChoice) {
+ LogUtils.d(TAG, "call showSingleChoiceDialog(...)");
+ yourChoice = nChoice;
+ AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
+ String sz = mfCurrentPath.getPath();
+
+ builder.setTitle(sz);
+ //builder.setCancelable(false);
+ builder.setSingleChoiceItems(szItems, nChoice,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ yourChoice = which;
+ }
+ });
+ // 确定按钮
+ builder.setPositiveButton("确定",
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(
+ DialogInterface dialog,
+ int which) {
+ mszResultPath = mfCurrentPath.getPath() + File.separator + szItems[yourChoice];
+ //Toast.makeText(mContext, mszResultPath, Toast.LENGTH_SHORT).show();
+ mOKClickListener.onOKClick(mszResultPath);
+ }
+ });
+ // 下一层文件按钮
+ builder.setNegativeButton(">>>", new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ File file = new File(mfCurrentPath.getPath() + File.separator + szItems[yourChoice]);
+ String[] szlist = getChildFileList(file);
+ if (szlist != null) {
+ mfCurrentPath = new File(mfCurrentPath.getPath() + File.separator + szItems[yourChoice]);
+
+ showSingleChoiceDialog(szlist, 0);
+
+ } else {
+ Toast.makeText(mContext, "这是一个最低的目录", Toast.LENGTH_SHORT).show();
+ String[] szlistOld = getChildFileList(mfCurrentPath);
+ showSingleChoiceDialog(szlistOld, yourChoice);
+ }
+
+ }
+ });
+ // 上一层文件按钮
+ builder.setNeutralButton("<<<",
+ new DialogInterface.OnClickListener() {// 添加返回按钮
+
+ @Override
+ public void onClick(
+ DialogInterface dialog,
+ int which) {// 响应事件
+
+ String[] szlist = getChildFileList(mfCurrentPath.getParentFile());
+ if (szlist != null) {
+ mfCurrentPath = mfCurrentPath.getParentFile();
+
+ showSingleChoiceDialog(szlist, 0);
+
+ } else {
+ Toast.makeText(mContext, "这是一个最高的目录", Toast.LENGTH_SHORT).show();
+ String[] szlistOld = getChildFileList(mfCurrentPath);
+ showSingleChoiceDialog(szlistOld, yourChoice);
+ }
+
+ }
+ });
+ AlertDialog dialog = builder.create();
+ dialog.show();
+
+ // 反射原理修改对话框元素
+ //
+ //需要在show()方法之后才能修改
+ //修改“确认”、“取消”按钮的字体大小
+ //dialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextSize(16);
+ //dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setTextSize(16);
+ //dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setTextSize(16);
+ try {
+ Field mAlert = AlertDialog.class.getDeclaredField("mAlert");
+ mAlert.setAccessible(true);
+ Object mAlertController = mAlert.get(dialog);
+
+ //通过反射修改title字体大小和颜色
+ Field mTitle = mAlertController.getClass().getDeclaredField("mTitleView");
+ mTitle.setAccessible(true);
+ TextView mTitleView = (TextView) mTitle.get(mAlertController);
+ //mTitleView.setTextSize(16);
+ //mTitleView.setTextColor(Color.RED);
+ mTitleView.setSingleLine(false);
+
+ //通过反射修改message字体大小和颜色
+ //Field mMessage = mAlertController.getClass().getDeclaredField("mMessageView");
+ //mMessage.setAccessible(true);
+ //TextView mMessageView = (TextView) mMessage.get(mAlertController);
+ //mMessageView.setTextSize(16);
+ //mMessageView.setTextColor(Color.GREEN);
+ } catch (IllegalAccessException e) {
+ LogUtils.d(TAG, "IllegalAccessException : " + e.getMessage());
+ } catch (NoSuchFieldException e) {
+ LogUtils.d(TAG, "NoSuchFieldException : " + e.getMessage());
+ }
+ }
+
+ public void setOnOKClickListener(OKClickListener listener) {
+ mOKClickListener = listener;
+ }
+
+ public interface OKClickListener {
+ void onOKClick(String szResultPath);
+ }
+
+ // 读取文件夹子目录
+ //
+ // "/storage/emulated/0"以上
+ // 和没有子目录的f参数返回空列表
+ protected String[] getChildFileList(File file) {
+ ArrayList szlistFiles = new ArrayList();
+ if (!file.getPath().equals("/storage/emulated")) {
+ File[] fileList = file.listFiles();
+ if (fileList != null) {
+ for (File fileItem : fileList) {
+ if (fileItem.getName().charAt(0) != '.') {
+ if (fileItem.isDirectory()) {
+ szlistFiles.add(fileItem.getName());
+ }
+ }
+ }
+ }
+ }
+ Collections.sort(szlistFiles, new SortChineseName(true));
+
+ if (szlistFiles.size() > 0) {
+ return szlistFiles.toArray(new String[szlistFiles.size()]);
+ } else {
+ return null;
+ }
+ }
+
+ private class SortChineseName implements Comparator {
+ private boolean mIsA2Z = true;
+ public SortChineseName(boolean isA2Z) {
+ mIsA2Z = isA2Z;
+ }
+ Collator cmp = Collator.getInstance(java.util.Locale.CHINA);
+ @Override
+ public int compare(String o1, String o2) {
+ if (mIsA2Z) {
+ if (cmp.compare(o1, o2) > 0) {
+ return 1;
+ } else if (cmp.compare(o1, o2) < 0) {
+ return -1;
+ }
+ } else {
+ if (cmp.compare(o1, o2) > 0) {
+ return -1;
+ } else if (cmp.compare(o1, o2) < 0) {
+ return 1;
+ }
+ }
+ return 0;
+ }
+ }
+
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/StoragePathDialog.java b/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/StoragePathDialog.java
new file mode 100644
index 0000000..2870a0f
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/StoragePathDialog.java
@@ -0,0 +1,57 @@
+package cc.winboll.studio.libaes.dialogs;
+
+import android.app.Dialog;
+import android.view.Gravity;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.utils.ScreenUtil;
+
+public class StoragePathDialog extends Dialog {
+
+ public static final String TAG = "StoragePathDialog";
+ View.OnClickListener mOnOKClickListener;
+
+ public StoragePathDialog(android.content.Context context) {
+ super(context);
+
+ }
+
+ public StoragePathDialog(android.content.Context context, int themeResId) {
+ super(context, (themeResId == 0) ? cc.winboll.studio.libaes.R.style.NormalDialogStyle: themeResId);
+ // 加载默认布局
+ View view = View.inflate(context, R.layout.dialog_storagepath, null);
+ setContentView(view);
+ // 添加按键点击监听
+ view.findViewById(R.id.dialogstoragepathButton1).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (mOnOKClickListener != null) {
+ mOnOKClickListener.onClick(view);
+ }
+ }
+ });
+ // 使得点击对话框外部不消失对话框
+ setCanceledOnTouchOutside(false);
+ // 设置对话框大小
+ ScreenUtil.ScreenSize ss = ScreenUtil.getScreenSize(context);
+ view.setMinimumHeight((int) (ss.getHeightPixels() * 0.23f));
+ Window dialogWindow = getWindow();
+ WindowManager.LayoutParams lp = dialogWindow.getAttributes();
+ lp.width = (int) (ss.getWidthPixels() * 0.75f);
+ lp.height = WindowManager.LayoutParams.WRAP_CONTENT;
+ lp.gravity = Gravity.CENTER;
+ dialogWindow.setAttributes(lp);
+
+ }
+
+ protected StoragePathDialog(android.content.Context context, boolean cancelable, android.content.DialogInterface.OnCancelListener cancelListener) {
+ super(context, cancelable, cancelListener);
+ }
+
+ public void setOnOKClickListener(View.OnClickListener listener) {
+ mOnOKClickListener = listener;
+ }
+
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/YesNoAlertDialog.java b/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/YesNoAlertDialog.java
new file mode 100644
index 0000000..f176daa
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/dialogs/YesNoAlertDialog.java
@@ -0,0 +1,60 @@
+package cc.winboll.studio.libaes.dialogs;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/03/28 17:40:47
+ * @Date 2024/08/12 14:46:25
+ * @Describe 询问用户确定与否的选择框
+ */
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+
+public class YesNoAlertDialog {
+
+ public static final String TAG = "YesNoAlertDialog";
+
+ public static void show(Context context, String szTitle, String szMessage, final OnDialogResultListener listener) {
+ AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(
+ context);
+
+ // set title
+ alertDialogBuilder.setTitle(szTitle);
+
+ // set dialog message
+ alertDialogBuilder
+ .setMessage(szMessage)
+ .setCancelable(true)
+ .setOnCancelListener(new DialogInterface.OnCancelListener(){
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ listener.onNo();
+ }
+ })
+ .setPositiveButton("YES", new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ // if this button is clicked, close
+ // current activity
+ listener.onYes();
+ }
+ })
+ .setNegativeButton("NO", new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int id) {
+ // if this button is clicked, just close
+ // the dialog box and do nothing
+ dialog.cancel();
+ }
+ });
+
+ // create alert dialog
+ AlertDialog alertDialog = alertDialogBuilder.create();
+
+ // show it
+ alertDialog.show();
+ }
+
+ public interface OnDialogResultListener {
+ abstract void onYes();
+ abstract void onNo();
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/enums/ADsMode.java b/libaes/src/main/java/cc/winboll/studio/libaes/enums/ADsMode.java
new file mode 100644
index 0000000..a43743c
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/enums/ADsMode.java
@@ -0,0 +1,32 @@
+package cc.winboll.studio.libaes.enums;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/26 17:49
+ * @Describe 广告控制模式枚举
+ */
+public enum ADsMode {
+ STANDALONE("单机模式"), // 单机模式(默认)
+ MIMO_SDK("米盟广告SDK支持模式"), // 米盟广告SDK模式
+ STORE_QRCODE("云宝物语模式"); // 米盟广告SDK模式
+
+ private final String modeName;
+
+ ADsMode(String modeName) {
+ this.modeName = modeName;
+ }
+
+ public String getModeName() {
+ return modeName;
+ }
+
+ // 根据保存的字符串值解析枚举(SP读取时使用)
+ public static ADsMode fromValue(String value) {
+ if (value == null) return STANDALONE;
+ try {
+ return ADsMode.valueOf(value);
+ } catch (IllegalArgumentException e) {
+ return STANDALONE;
+ }
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/enums/PrivacyAgreeStatus.java b/libaes/src/main/java/cc/winboll/studio/libaes/enums/PrivacyAgreeStatus.java
new file mode 100644
index 0000000..fe9e133
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/enums/PrivacyAgreeStatus.java
@@ -0,0 +1,67 @@
+package cc.winboll.studio.libaes.enums;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/27 12:35
+ * @Describe 隐私协议签约状态枚举
+ * 对应值:0-拒绝,1-赞同,2-未签约(默认)
+ */
+public enum PrivacyAgreeStatus {
+ REJECTED(0, "拒绝"), // 0: 拒绝隐私协议
+ AGREED(1, "赞同"), // 1: 赞同隐私协议
+ UN_SIGNED(2, "未签约"); // 2: 未签约(初始默认状态)
+
+ private final int statusCode; // 对应存储的int值
+ private final String statusDesc; // 状态描述(可选,便于日志/UI显示)
+
+ // Java 7 枚举构造方法(必须private)
+ private PrivacyAgreeStatus(int statusCode, String statusDesc) {
+ this.statusCode = statusCode;
+ this.statusDesc = statusDesc;
+ }
+
+ /**
+ * 根据int值获取枚举(SP读取时使用,兼容Java 7)
+ * @param code 存储的int值(0/1/2)
+ * @return 对应枚举,默认返回UN_SIGNED(未签约)
+ */
+ public static PrivacyAgreeStatus fromCode(int code) {
+ // Java 7 不支持switch(String),用if-else兼容
+ if (code == REJECTED.statusCode) {
+ return REJECTED;
+ } else if (code == AGREED.statusCode) {
+ return AGREED;
+ } else {
+ return UN_SIGNED; // 默认未签约
+ }
+ }
+
+ /**
+ * 根据SP存储的字符串值获取枚举(兼容原逻辑中String类型存储)
+ * @param codeStr 存储的字符串值("0"/"1"/"2")
+ * @return 对应枚举,默认返回UN_SIGNED(未签约)
+ */
+ public static PrivacyAgreeStatus fromString(String codeStr) {
+ if (codeStr == null) {
+ return UN_SIGNED;
+ }
+ try {
+ int code = Integer.parseInt(codeStr);
+ return fromCode(code);
+ } catch (NumberFormatException e) {
+ // 字符串格式异常时,默认返回未签约
+ return UN_SIGNED;
+ }
+ }
+
+ // 获取状态码(用于存储到SP)
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ // 获取状态描述(用于日志/UI显示,可选)
+ public String getStatusDesc() {
+ return statusDesc;
+ }
+}
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/interfaces/IWinBoLLActivity.java b/libaes/src/main/java/cc/winboll/studio/libaes/interfaces/IWinBoLLActivity.java
new file mode 100644
index 0000000..9760d2b
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/interfaces/IWinBoLLActivity.java
@@ -0,0 +1,24 @@
+package cc.winboll.studio.libaes.interfaces;
+
+import android.app.Activity;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/05/10 09:34
+ * @Describe WinBoll 窗口操作接口(规范定义,职责单一)
+ */
+public interface IWinBoLLActivity {
+ String TAG = "IWinBoLLActivity";
+ String ACTION_BIND = IWinBoLLActivity.class.getName() + ".ACTION_BIND";
+
+ /**
+ * 获取当前Activity实例
+ */
+ Activity getActivity();
+
+ /**
+ * 获取Activity唯一标识(建议使用类名+UUID或固定唯一字符串)
+ */
+ String getTag();
+}
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/models/AESThemeBean.java b/libaes/src/main/java/cc/winboll/studio/libaes/models/AESThemeBean.java
new file mode 100644
index 0000000..dd4d8d8
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/models/AESThemeBean.java
@@ -0,0 +1,143 @@
+package cc.winboll.studio.libaes.models;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/06/14 02:42:57
+ * @Describe 主题元素项目类
+ */
+import android.util.JsonReader;
+import android.util.JsonWriter;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libappbase.BaseBean;
+import java.io.IOException;
+
+public class AESThemeBean extends BaseBean {
+
+ public static final String TAG = "AESThemeBean";
+
+ public enum ThemeType {
+ AES("默认主题"),
+ DEPTH("深奥主题"),
+ SKY("天空主题"),
+ GOLDEN("辉煌主题"),
+ BEARING("智芋主题"),
+ MEMOR("梦箩主题"),
+ TAO("黑白主题");
+
+ private String name;
+
+ // 枚举构造函数
+ ThemeType(String name) {
+ this.name = name;
+ }
+
+ // 将字符串转换为枚举
+ public static ThemeType fromString(String themeTypeStr) {
+ return ThemeType.valueOf(themeTypeStr.toUpperCase()); // 注意这里用了toUpperCase(),确保匹配时不区分大小写
+ }
+
+ // 获取枚举的名称
+ public String getName() {
+ return name;
+ }
+ }
+
+ // 保存当前主题
+ int currentThemeStyleID = getThemeStyleID(ThemeType.AES);
+
+ public AESThemeBean() {
+ }
+
+ public AESThemeBean(int currentThemeStyleID) {
+ this.currentThemeStyleID = currentThemeStyleID;
+ }
+
+ public void setCurrentThemeTypeID(int currentThemeTypeID) {
+ this.currentThemeStyleID = currentThemeTypeID;
+ }
+
+ public int getCurrentThemeTypeID() {
+ return this.currentThemeStyleID;
+ }
+
+ @Override
+ public String getName() {
+ return AESThemeBean.class.getName();
+ }
+
+ @Override
+ public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException {
+ super.writeThisToJsonWriter(jsonWriter);
+ AESThemeBean bean = this;
+ jsonWriter.name("currentThemeTypeID").value(bean.getCurrentThemeTypeID());
+ }
+
+ @Override
+ public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException {
+ if(super.initObjectsFromJsonReader(jsonReader, name)) { return true; }
+ else{
+ if (name.equals("currentThemeTypeID")) {
+ setCurrentThemeTypeID(jsonReader.nextInt());
+ } else {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException {
+ jsonReader.beginObject();
+ while (jsonReader.hasNext()) {
+ String name = jsonReader.nextName();
+ if(!initObjectsFromJsonReader(jsonReader, name)) {
+ jsonReader.skipValue();
+ }
+ }
+ // 结束 JSON 对象
+ jsonReader.endObject();
+ return this;
+ }
+
+ public static int getThemeStyleID(ThemeType themeType) {
+ int themeStyleID = R.style.AESTheme;
+ if (AESThemeBean.ThemeType.DEPTH == themeType) {
+ themeStyleID = R.style.DepthAESTheme;
+ } else if (AESThemeBean.ThemeType.SKY == themeType) {
+ themeStyleID = R.style.SkyAESTheme;
+ } else if (AESThemeBean.ThemeType.GOLDEN == themeType) {
+ themeStyleID = R.style.GoldenAESTheme;
+ } else if (AESThemeBean.ThemeType.BEARING == themeType) {
+ themeStyleID = R.style.BearingAESTheme;
+ } else if (AESThemeBean.ThemeType.MEMOR == themeType) {
+ themeStyleID = R.style.MemorAESTheme;
+ } else if (AESThemeBean.ThemeType.TAO == themeType) {
+ themeStyleID = R.style.TaoAESTheme;
+ } else if (AESThemeBean.ThemeType.AES == themeType) {
+ themeStyleID = R.style.AESTheme;
+ }
+ //LogUtils.d(TAG, "themeStyleID " + Integer.toString(themeStyleID));
+ return themeStyleID;
+ }
+
+ public static AESThemeBean.ThemeType getThemeStyleType(int nThemeStyleID) {
+ AESThemeBean.ThemeType themeStyle = AESThemeBean.ThemeType.AES;
+ if (R.style.DepthAESTheme == nThemeStyleID) {
+ themeStyle = AESThemeBean.ThemeType.DEPTH ;
+ } else if (R.style.SkyAESTheme == nThemeStyleID) {
+ themeStyle = AESThemeBean.ThemeType.SKY ;
+ } else if (R.style.GoldenAESTheme == nThemeStyleID) {
+ themeStyle = AESThemeBean.ThemeType.GOLDEN ;
+ } else if (R.style.BearingAESTheme == nThemeStyleID) {
+ themeStyle = AESThemeBean.ThemeType.BEARING ;
+ } else if (R.style.MemorAESTheme == nThemeStyleID) {
+ themeStyle = AESThemeBean.ThemeType.MEMOR ;
+ } else if (R.style.TaoAESTheme == nThemeStyleID) {
+ themeStyle = AESThemeBean.ThemeType.TAO ;
+ } else if (R.style.AESTheme == nThemeStyleID) {
+ themeStyle = AESThemeBean.ThemeType.AES;
+ }
+ //LogUtils.d(TAG, "themeStyle " + Integer.toString(themeStyle.ordinal()));
+ return themeStyle;
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/models/APPInfo.java b/libaes/src/main/java/cc/winboll/studio/libaes/models/APPInfo.java
new file mode 100644
index 0000000..cf097e0
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/models/APPInfo.java
@@ -0,0 +1,169 @@
+package cc.winboll.studio.libaes.models;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/01/20 14:19:02
+ * @Describe 应用信息类
+ */
+import cc.winboll.studio.libaes.R;
+import java.io.Serializable;
+
+public class APPInfo implements Serializable {
+
+ public static final String TAG = "APPInfo";
+
+ // 应用名称
+ String appName;
+ // 应用图标
+ int appIcon;
+ // 应用描述
+ String appDescription;
+ // 应用Git仓库地址
+ String appGitName;
+ // 应用Git仓库拥有者
+ String appGitOwner;
+ // 应用Git仓库分支
+ String appGitAPPBranch;
+ // 应用Git仓库子项目文件夹
+ String appGitAPPSubProjectFolder;
+ // 应用主页
+ String appHomePage;
+ // 应用包名称
+ String appAPKName;
+ // 应用包存储文件夹名称
+ String appAPKFolderName;
+ // 是否添加调试工具
+ boolean isAddDebugTools;
+
+ public APPInfo(String appName, int appIcon, String appDescription, String appGitName, String appGitOwner, String appGitAPPBranch, String appGitAPPSubProjectFolder, String appHomePage, String appAPKName, String appAPKFolderName) {
+ this.appName = appName;
+ this.appIcon = appIcon;
+ this.appDescription = appDescription;
+ this.appGitName = appGitName;
+ this.appGitOwner = appGitOwner;
+ this.appGitAPPBranch = appGitAPPBranch;
+ this.appGitAPPSubProjectFolder = appGitAPPSubProjectFolder;
+ this.appHomePage = appHomePage;
+ this.appAPKName = appAPKName;
+ this.appAPKFolderName = appAPKFolderName;
+ this.isAddDebugTools = false;
+ }
+
+ public APPInfo(String appName, int appIcon, String appDescription, String appGitName, String appGitOwner, String appGitAPPBranch, String appGitAPPSubProjectFolder, String appHomePage, String appAPKName, String appAPKFolderName, boolean isAddDebugTools) {
+ this.appName = appName;
+ this.appIcon = appIcon;
+ this.appDescription = appDescription;
+ this.appGitName = appGitName;
+ this.appGitOwner = appGitOwner;
+ this.appGitAPPBranch = appGitAPPBranch;
+ this.appGitAPPSubProjectFolder = appGitAPPSubProjectFolder;
+ this.appHomePage = appHomePage;
+ this.appAPKName = appAPKName;
+ this.appAPKFolderName = appAPKFolderName;
+ this.isAddDebugTools = isAddDebugTools;
+ }
+
+ public APPInfo() {
+ String szBranchName = "app";
+ this.appName = "APP";
+ this.appIcon = R.drawable.ic_launcher;
+ this.appDescription = "APP Description";
+ this.appGitName = "APP";
+ this.appGitOwner = "Studio";
+ this.appGitAPPBranch = szBranchName;
+ this.appGitAPPSubProjectFolder = szBranchName;
+ this.appHomePage = "https://www.winboll.cc/studio/details.php?app=APP";
+ this.appAPKName = "APP";
+ this.appAPKFolderName = "APP";
+ this.isAddDebugTools = false;
+ }
+
+ public void setIsAddDebugTools(boolean isAddDebugTools) {
+ this.isAddDebugTools = isAddDebugTools;
+ }
+
+ public boolean isAddDebugTools() {
+ return isAddDebugTools;
+ }
+
+ public void setAppGitOwner(String appGitOwner) {
+ this.appGitOwner = appGitOwner;
+ }
+
+ public String getAppGitOwner() {
+ return appGitOwner;
+ }
+
+ public void setAppGitAPPBranch(String appGitAPPBranch) {
+ this.appGitAPPBranch = appGitAPPBranch;
+ }
+
+ public String getAppGitAPPBranch() {
+ return appGitAPPBranch;
+ }
+
+ public void setAppGitAPPSubProjectFolder(String appGitAPPSubProjectFolder) {
+ this.appGitAPPSubProjectFolder = appGitAPPSubProjectFolder;
+ }
+
+ public String getAppGitAPPSubProjectFolder() {
+ return appGitAPPSubProjectFolder;
+ }
+
+ public void setAppIcon(int appIcon) {
+ this.appIcon = appIcon;
+ }
+
+ public int getAppIcon() {
+ return appIcon;
+ }
+
+ public void setAppDescription(String appDescription) {
+ this.appDescription = appDescription;
+ }
+
+ public String getAppDescription() {
+ return appDescription;
+ }
+
+ public void setAppAPKFolderName(String appAPKFolderName) {
+ this.appAPKFolderName = appAPKFolderName;
+ }
+
+ public String getAppAPKFolderName() {
+ return appAPKFolderName;
+ }
+
+ public void setAppName(String appName) {
+ this.appName = appName;
+ }
+
+ public String getAppName() {
+ return appName;
+ }
+
+ public void setAppGitName(String appGitName) {
+ this.appGitName = appGitName;
+ }
+
+ public String getAppGitName() {
+ return appGitName;
+ }
+
+ public void setAppHomePage(String appHomePage) {
+ this.appHomePage = appHomePage;
+ }
+
+ public String getAppHomePage() {
+ return appHomePage;
+ }
+
+ public void setAppAPKName(String appAPKName) {
+ this.appAPKName = appAPKName;
+ }
+
+ public String getAppAPKName() {
+ return appAPKName;
+ }
+}
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/models/DrawerMenuBean.java b/libaes/src/main/java/cc/winboll/studio/libaes/models/DrawerMenuBean.java
new file mode 100644
index 0000000..662d4d4
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/models/DrawerMenuBean.java
@@ -0,0 +1,35 @@
+package cc.winboll.studio.libaes.models;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/06/14 01:53:34
+ * @Describe 抽屉菜单项目类
+ */
+public class DrawerMenuBean {
+
+ public static final String TAG = "DrawerMenuBean";
+
+ private int iconId;
+ private String iconName;
+
+ public DrawerMenuBean(int iconId, String iconName) {
+ this.iconId = iconId;
+ this.iconName = iconName;
+ }
+
+ public int getIconId() {
+ return iconId;
+ }
+
+ public String getIconName() {
+ return iconName;
+ }
+
+ public void setIconId(int iconId) {
+ this.iconId = iconId;
+ }
+
+ public void setIconName(String iconName) {
+ this.iconName = iconName;
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/models/WinBoLLClientServiceBean.java b/libaes/src/main/java/cc/winboll/studio/libaes/models/WinBoLLClientServiceBean.java
new file mode 100644
index 0000000..dcd112b
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/models/WinBoLLClientServiceBean.java
@@ -0,0 +1,78 @@
+package cc.winboll.studio.libaes.models;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/05/03 19:16
+ */
+import android.content.Context;
+import android.util.JsonReader;
+import android.util.JsonWriter;
+import cc.winboll.studio.libappbase.BaseBean;
+import java.io.IOException;
+
+public class WinBoLLClientServiceBean extends BaseBean {
+
+ public static final String TAG = "WinBoLLClientServiceBean";
+
+ // 服务是否正在使用中
+ boolean isEnable;
+
+ public WinBoLLClientServiceBean() {
+ this.isEnable = false;
+ }
+
+ public WinBoLLClientServiceBean(boolean isEnable) {
+ this.isEnable = isEnable;
+ }
+
+ public void setIsEnable(boolean isEnable) {
+ this.isEnable = isEnable;
+ }
+
+ public boolean isEnable() {
+ return isEnable;
+ }
+
+
+ @Override
+ public String getName() {
+ return WinBoLLClientServiceBean.class.getName();
+ }
+
+ @Override
+ public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException {
+ super.writeThisToJsonWriter(jsonWriter);
+ WinBoLLClientServiceBean bean = this;
+ //jsonWriter.name("logLevel").value(bean.getLogLevel().ordinal());
+ }
+
+ @Override
+ public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException {
+ if (super.initObjectsFromJsonReader(jsonReader, name)) { return true; } else {
+// if (name.equals("logLevel")) {
+// setLogLevel(LogUtils.LOG_LEVEL.values()[jsonReader.nextInt()]);
+// } else {
+// return false;
+// }
+ }
+ return true;
+ }
+
+ @Override
+ public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException {
+ jsonReader.beginObject();
+ while (jsonReader.hasNext()) {
+ String name = jsonReader.nextName();
+ if (!initObjectsFromJsonReader(jsonReader, name)) {
+ jsonReader.skipValue();
+ }
+ }
+ // 结束 JSON 对象
+ jsonReader.endObject();
+ return this;
+ }
+
+ public static WinBoLLClientServiceBean loadWinBoLLClientServiceBean(Context context) {
+ return new WinBoLLClientServiceBean();
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/unittests/SecondaryLibraryActivity.java b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/SecondaryLibraryActivity.java
new file mode 100644
index 0000000..b5b210d
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/SecondaryLibraryActivity.java
@@ -0,0 +1,57 @@
+package cc.winboll.studio.libaes.unittests;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Toast;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.activitys.DrawerFragmentActivity;
+import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/06/15 00:58:10
+ * @Describe 第二级窗口
+ */
+public class SecondaryLibraryActivity extends DrawerFragmentActivity implements IWinBoLLActivity {
+
+ public static final String TAG = "SecondaryLibraryActivity";
+
+ SecondaryLibraryFragment mSecondaryLibraryFragment;
+
+ @Override
+ public String getTag() {
+ return null;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (mSecondaryLibraryFragment == null) {
+ mSecondaryLibraryFragment = new SecondaryLibraryFragment();
+ addFragment(mSecondaryLibraryFragment);
+ }
+ showFragment(mSecondaryLibraryFragment);
+ }
+
+ @Override
+ public DrawerFragmentActivity.ActivityType initActivityType() {
+ return DrawerFragmentActivity.ActivityType.Secondary;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.toolbar_secondarylibrary, menu);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int nItemId = item.getItemId();
+ if (nItemId == R.id.item_test) {
+ Toast.makeText(getApplicationContext(), "item_test", Toast.LENGTH_SHORT).show();
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/unittests/SecondaryLibraryFragment.java b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/SecondaryLibraryFragment.java
new file mode 100644
index 0000000..c0ddc14
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/SecondaryLibraryFragment.java
@@ -0,0 +1,25 @@
+package cc.winboll.studio.libaes.unittests;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 02:36:34
+ * @Describe SecondaryLibraryFragment
+ */
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.fragment.app.Fragment;
+import cc.winboll.studio.libaes.R;
+
+public class SecondaryLibraryFragment extends Fragment {
+
+ public static final String TAG = "SecondaryLibraryFragment";
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.fragment_secondarylibrary, container, false);
+
+ return view;
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestAButtonFragment.java b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestAButtonFragment.java
new file mode 100644
index 0000000..ce837aa
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestAButtonFragment.java
@@ -0,0 +1,37 @@
+package cc.winboll.studio.libaes.unittests;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:27:50
+ * @Describe TestAButtonFragment
+ */
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.fragment.app.Fragment;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.views.AButton;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.ToastUtils;
+
+public class TestAButtonFragment extends Fragment {
+
+ public static final String TAG = "TestAButtonFragment";
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.fragment_abutton, container, false);
+ AButton aButton = view.findViewById(R.id.fragmentabuttonAButton1);
+ aButton.setOnClickListener(new View.OnClickListener() {
+
+ @Override
+ public void onClick(View view) {
+ LogUtils.d(TAG, "onClick");
+ ToastUtils.show("AButton");
+ }
+
+ });
+ return view;
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestASupportToolbarActivity.java b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestASupportToolbarActivity.java
new file mode 100644
index 0000000..d014654
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestASupportToolbarActivity.java
@@ -0,0 +1,47 @@
+package cc.winboll.studio.libaes.unittests;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:14:00
+ * @Describe TestASupportToolbarActivity
+ */
+import android.app.Activity;
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
+import cc.winboll.studio.libaes.utils.AESThemeUtil;
+import cc.winboll.studio.libaes.views.ASupportToolbar;
+import cc.winboll.studio.libappbase.LogUtils;
+
+public class TestASupportToolbarActivity extends AppCompatActivity implements IWinBoLLActivity {
+
+ public static final String TAG = "TestASupportToolbarActivity";
+
+ @Override
+ public Activity getActivity() {
+ return this;
+ }
+
+ @Override
+ public String getTag() {
+ return TAG;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ LogUtils.d(TAG, "onCreate() start");
+ AESThemeUtil.applyAppTheme(this);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_testasupporttoolbar);
+ LogUtils.d(TAG, "setContentView() done");
+ Toolbar toolbar = findViewById(R.id.activitytestasupporttoolbarASupportToolbar1);
+ LogUtils.d(TAG, "findViewById() done, toolbar=" + toolbar.getClass().getSimpleName());
+ setSupportActionBar(toolbar);
+ LogUtils.d(TAG, "setSupportActionBar() done");
+ getSupportActionBar().setTitle(TAG);
+ LogUtils.d(TAG, "setTitle() done");
+ LogUtils.d(TAG, "onCreate() end");
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestAToolbarActivity.java b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestAToolbarActivity.java
new file mode 100644
index 0000000..bba25fc
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestAToolbarActivity.java
@@ -0,0 +1,28 @@
+package cc.winboll.studio.libaes.unittests;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:16:07
+ * @Describe TestAToolbarActivity
+ */
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.Toolbar;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.utils.AESThemeUtil;
+
+public class TestAToolbarActivity extends Activity {
+
+ public static final String TAG = "TestAToolbarActivity";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ AESThemeUtil.applyAppTheme(this);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_testatoolbar);
+ Toolbar toolbar = findViewById(R.id.activitytestatoolbarAToolbar1);
+ setActionBar(toolbar);
+ getActionBar().setTitle(TAG);
+ }
+
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestDrawerFragmentActivity.java b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestDrawerFragmentActivity.java
new file mode 100644
index 0000000..ab4fc2b
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestDrawerFragmentActivity.java
@@ -0,0 +1,124 @@
+package cc.winboll.studio.libaes.unittests;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/06/30 15:00:51
+ */
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.Toast;
+import androidx.fragment.app.Fragment;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.activitys.DrawerFragmentActivity;
+import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
+import cc.winboll.studio.libaes.models.DrawerMenuBean;
+import cc.winboll.studio.libappbase.LogUtils;
+import java.util.ArrayList;
+
+public class TestDrawerFragmentActivity extends DrawerFragmentActivity implements IWinBoLLActivity {
+
+ @Override
+ public Activity getActivity() {
+ return this;
+ }
+
+ @Override
+ public String getTag() {
+ return null;
+ }
+
+ public static final String TAG = "TestDrawerFragmentActivity";
+
+ TestFragment1 mTestFragment1;
+ TestFragment2 mTestFragment2;
+
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mTestFragment1 = new TestFragment1();
+ addFragment(mTestFragment1);
+ mTestFragment2 = new TestFragment2();
+ addFragment(mTestFragment2);
+ showFragment(0);
+ }
+
+ @Override
+ protected DrawerFragmentActivity.ActivityType initActivityType() {
+ return DrawerFragmentActivity.ActivityType.Main;
+ }
+
+ @Override
+ public void initDrawerMenuItemList(ArrayList listDrawerMenu) {
+ super.initDrawerMenuItemList(listDrawerMenu);
+ LogUtils.d(TAG, "initDrawerMenuItemList");
+ //listDrawerMenu.clear();
+ // 添加抽屉菜单项
+ listDrawerMenu.add(new DrawerMenuBean(R.drawable.ic_launcher, TestFragment1.TAG));
+ listDrawerMenu.add(new DrawerMenuBean(R.drawable.ic_launcher, TestFragment2.TAG));
+ notifyDrawerMenuDataChanged();
+ }
+
+ @Override
+ public void reinitDrawerMenuItemList(ArrayList listDrawerMenu) {
+ super.reinitDrawerMenuItemList(listDrawerMenu);
+ LogUtils.d(TAG, "reinitDrawerMenuItemList");
+ //listDrawerMenu.clear();
+ // 添加抽屉菜单项
+ listDrawerMenu.add(new DrawerMenuBean(R.drawable.ic_launcher, TestFragment1.TAG));
+ listDrawerMenu.add(new DrawerMenuBean(R.drawable.ic_launcher, TestFragment2.TAG));
+ notifyDrawerMenuDataChanged();
+ }
+
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ super.onItemClick(parent, view, position, id);
+ switch (position) {
+ case 0 : {
+ Toast.makeText(getApplicationContext(), "0", Toast.LENGTH_SHORT).show();
+ //LogUtils.d(TAG, "MenuItem 1");
+ showFragment(mTestFragment1);
+ break;
+ }
+ case 1 : {
+ //LogUtils.d(TAG, "MenuItem 2");
+ showFragment(mTestFragment2);
+ break;
+ }
+
+ }
+ }
+
+ public static class TestFragment1 extends Fragment {
+
+ public static final String TAG = "TestFragment1";
+
+ View mView;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mView = inflater.inflate(R.layout.fragment_test1, container, false);
+
+ return mView;
+ }
+
+ }
+
+ public static class TestFragment2 extends Fragment {
+
+ public static final String TAG = "TestFragment2";
+
+ View mView;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mView = inflater.inflate(R.layout.fragment_test2, container, false);
+
+ return mView;
+ }
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestViewPageFragment.java b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestViewPageFragment.java
new file mode 100644
index 0000000..1d922fa
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/unittests/TestViewPageFragment.java
@@ -0,0 +1,226 @@
+package cc.winboll.studio.libaes.unittests;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:35:56
+ * @Describe TestViewPageFragment
+ */
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import androidx.fragment.app.Fragment;
+import androidx.viewpager.widget.ViewPager;
+import cc.winboll.studio.libaes.ImagePagerAdapter;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.views.AOHPCTCSeekBar;
+import cc.winboll.studio.libappbase.LogView;
+import cc.winboll.studio.libappbase.ToastUtils;
+import java.util.ArrayList;
+import java.util.List;
+
+public class TestViewPageFragment extends Fragment implements ViewPager.OnPageChangeListener, View.OnClickListener {
+
+ public static final String TAG = "TestViewPageFragment";
+
+ Context mContext;
+ LogView mLogView;
+
+ private ViewPager viewPager;
+ private List views; //用来存放放进ViewPager里面的布局
+ //实例化存储imageView(导航原点)的集合
+ ImageView[] imageViews;
+ private ImagePagerAdapter adapter;//适配器
+ private LinearLayout linearLayout;//下标所在在LinearLayout布局里
+ private int currentPoint = 0;//当前被选中中页面的下标
+ View mView;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mView = inflater.inflate(R.layout.fragment_viewpage, container, false);
+ mContext = getActivity();
+
+ mLogView = mView.findViewById(R.id.logview);
+ mLogView.start();
+
+ //viewPager = findViewById(R.id.activitymainViewPager1);
+ initData();
+ initView();//调用初始化视图方法
+ initPoint();//调用初始化导航原点的方法
+ viewPager.addOnPageChangeListener(this);//滑动事件
+ //viewPager.setAdapter(new MyAdapter());
+
+ // 获取屏幕参数
+ //ScreenUtil.ScreenSize ss = ScreenUtil.getScreenSize(MainActivity.this);
+ //Toast.makeText(getApplication(), Integer.toString(ss.getHeightPixels())+" "+Integer.toString(ss.getWidthPixels()), Toast.LENGTH_SHORT).show();
+
+ return mView;
+ }
+
+ //初始化view,即显示的图片
+ void initView() {
+ adapter = new ImagePagerAdapter(views);
+ viewPager = mView.findViewById(R.id.fragmentviewpageViewPager1);
+ viewPager.setAdapter(adapter);
+ linearLayout = mView.findViewById(R.id.fragmentviewpageLinearLayout1);
+ initPoint();//初始化页面下方的点
+ viewPager.setOnPageChangeListener(this);
+ initAOHPCTCSeekBar();
+ initAOHPCTCSeekBar2();
+ }
+
+ //初始化所要显示的布局
+ void initData() {
+ ViewPager viewPager = mView.findViewById(R.id.fragmentviewpageViewPager1);
+ LayoutInflater inflater = LayoutInflater.from(mContext);
+ View view1 = inflater.inflate(R.layout.viewpage_atickprogressbar, viewPager, false);
+ View view2 = inflater.inflate(R.layout.viewpage_acard, viewPager, false);
+ View view3 = inflater.inflate(R.layout.viewpage_aohpctccard, viewPager, false);
+ View view4 = inflater.inflate(R.layout.viewpage_aohpctcsb, viewPager, false);
+
+ views = new ArrayList<>();
+ views.add(view1);
+ views.add(view2);
+ views.add(view3);
+ views.add(view4);
+ }
+
+ //setTag注释
+ /*
+ //View中的setTag(Onbect)表示给View添加一个格外的数据,以后可以用getTag()将这个数据取出来。来
+ 代表这个数据,即实例化
+ Tag是标签的bai意识,这里的tag是object类型。所以通常会使用setTag()设置不同的Object子类对象,
+ 然后使用强制转换getTag()获得对象。
+ //可以用在多个Button添加一个监听器,每个Button都设置不同的setTag。
+ 这个监听器就通过getTag来分辨是哪个Button 被按下。
+ public class Main extends Activity {
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.main);
+ Button button1 = (Button) findViewById(R.id.Button01);
+ Button button2 = (Button) findViewById(R.id.Button02);
+ Button button3 = (Button) findViewById(R.id.Button03);
+ Button button4 = (Button) findViewById(R.id.Button04);
+ MyListener listener = new MyListener();
+ button1.setTag(1);
+ button1.setOnClickListener(listener);
+ button2.setTag(2);
+ button2.setOnClickListener(listener);
+ button3.setTag(3);
+ button3.setOnClickListener(listener);
+ button4.setTag(4);
+ button4.setOnClickListener(listener);
+ }
+
+ public class MyListener implements View.OnClickListener {
+
+ @Override
+ public void onClick(View v) {
+ int tag = (Integer) v.getTag();
+ switch (tag) {
+ case 1:
+ System.out.println(“button1 click”);
+ break;
+ case 2:
+ System.out.println(“button2 click”);
+ break;
+ case 3:
+ System.out.println(“button3 click”);
+ break;
+ case 4:
+ System.out.println(“button4 click”);
+ break;
+ }
+
+ */
+
+ private void initPoint() {
+
+ imageViews = new ImageView[5];//实例化5个图片
+ for (int i = 0; i < linearLayout.getChildCount(); i++) {
+ imageViews[i] = (ImageView) linearLayout.getChildAt(i);
+ imageViews[i].setImageResource(R.drawable.ic_arrow_left_right_bold);
+ imageViews[i].setOnClickListener(this);//点击导航点,即可跳转
+ imageViews[i].setTag(i);//重复利用实例化的对象
+ }
+ currentPoint = 0;//默认第一个坐标
+ imageViews[currentPoint].setImageResource(R.drawable.ic_arrow_up_circle_outline);
+ }
+
+ //OnPageChangeListener接口要实现的三个方法
+ /* onPageScrollStateChanged(int state)
+ 此方法是在状态改变的时候调用,其中state这个参数有三种状态:
+ SCROLL_STATE_DRAGGING(1)表示用户手指“按在屏幕上并且开始拖动”的状态
+ (手指按下但是还没有拖动的时候还不是这个状态,只有按下并且手指开始拖动后log才打出。)
+ SCROLL_STATE_IDLE(0)滑动动画做完的状态。
+ SCROLL_STATE_SETTLING(2)在“手指离开屏幕”的状态。*/
+ @Override
+ public void onPageScrollStateChanged(int state) {
+
+ }
+ /* onPageScrolled(int position, float positionOffset, int positionOffsetPixels)
+ 当页面在滑动的时候会调用此方法,在滑动被停止之前,此方法回一直得到调用。其中三个参数的含义分别为:
+
+ position :当前页面,即你点击滑动的页面(从A滑B,则是A页面的position。
+ positionOffset:当前页面偏移的百分比
+ positionOffsetPixels:当前页面偏移的像素位置*/
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+
+ }
+ /* onPageSelected(int position)
+ 此方法是页面滑动完后得到调用,position是你当前选中的页面的Position(位置编号)
+ (从A滑动到B,就是B的position)*/
+ public void onPageSelected(int position) {
+
+ ImageView preView = imageViews[currentPoint];
+ preView.setImageResource(R.drawable.ic_arrow_left_right_bold);
+ ImageView currView = imageViews[position];
+ currView.setImageResource(R.drawable.ic_arrow_up_circle_outline);
+ currentPoint = position;
+ }
+
+ //小圆点点击事件
+ @Override
+ public void onClick(View v) {
+ // TODO Auto-generated method stub
+ //通过getTag(),可以判断是哪个控件
+ int i = (Integer) v.getTag();
+ viewPager.setCurrentItem(i);//直接跳转到某一个页面的情况
+ }
+
+ void initAOHPCTCSeekBar() {
+ AOHPCTCSeekBar seekbar = views.get(3).findViewById(R.id.fragmentviewpageAOHPCTCSeekBar1);
+ seekbar.setThumb(mContext.getDrawable(R.drawable.ic_launcher));
+ //seekbar.setThumbOffset(200);
+ //seekbar.setThumbOffset(1);
+ seekbar.setBlurRightDP(50);
+ seekbar.setOnOHPCListener(new AOHPCTCSeekBar.OnOHPCListener() {
+
+ @Override
+ public void onOHPCommit() {
+ ToastUtils.show("onOHPCommit");
+ }
+ });
+ }
+
+ void initAOHPCTCSeekBar2() {
+ AOHPCTCSeekBar seekbar = views.get(3).findViewById(R.id.fragmentviewpageAOHPCTCSeekBar2);
+ seekbar.setThumb(mContext.getDrawable(R.drawable.ic_call));
+ //seekbar.setThumbOffset(200);
+ //seekbar.setThumbOffset(1);
+ seekbar.setBlurRightDP(50);
+ seekbar.setOnOHPCListener(new AOHPCTCSeekBar.OnOHPCListener() {
+
+ @Override
+ public void onOHPCommit() {
+ ToastUtils.show("onOHPCommit 2");
+ }
+ });
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/AESThemeUtil.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/AESThemeUtil.java
new file mode 100644
index 0000000..b4e7548
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/AESThemeUtil.java
@@ -0,0 +1,211 @@
+package cc.winboll.studio.libaes.utils;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/11/29 22:52:09
+ * @Describe AES 主题工具集
+ */
+import android.app.Activity;
+import android.content.Context;
+import android.view.Menu;
+import android.view.MenuItem;
+import androidx.appcompat.app.AppCompatActivity;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.activitys.DrawerFragmentActivity;
+import cc.winboll.studio.libaes.models.AESThemeBean;
+
+public class AESThemeUtil {
+
+ public static final String TAG = "AESThemeUtil";
+
+ static final String SHAREDPREFERENCES_NAME = "SHAREDPREFERENCES_NAME";
+ static final String DRAWER_THEME_TYPE = "DRAWER_THEME_TYPE";
+
+ protected volatile AESThemeBean.ThemeType mThemeType;
+
+ public static int getThemeTypeID(T context) {
+ AESThemeBean bean = AESThemeBean.loadBean(context, AESThemeBean.class);
+ return bean == null ? AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.AES): bean.getCurrentThemeTypeID();
+ }
+
+ public static void saveThemeStyleID(T context, int nThemeTypeID) {
+ AESThemeBean bean = new AESThemeBean(nThemeTypeID);
+ AESThemeBean.saveBean(context, bean);
+ }
+
+ public static void applyAppTheme(T activity) {
+ activity.setTheme(getThemeTypeID(activity));
+ }
+
+ public static void applyAppCompatTheme(T activity) {
+ activity.setTheme(getThemeTypeID(activity));
+ }
+
+ /*public static void applyWinBoLLTheme(T activity) {
+ activity.setTheme(getThemeTypeID(activity.getApplicationContext()));
+ }*/
+
+ public static void applyAppTheme(Activity activity, AESThemeBean.ThemeType themeType) {
+ activity.setTheme(AESThemeBean.getThemeStyleID(themeType));
+ }
+
+ public static void applyAppCompatTheme(Activity activity, AESThemeBean.ThemeType themeType) {
+ activity.setTheme(AESThemeBean.getThemeStyleID(themeType));
+ }
+
+ /*public static void applyWinBoLLTheme(Activity activity, AESThemeBean.ThemeType themeType) {
+ activity.setTheme(AESThemeBean.getThemeStyleID(themeType));
+ }*/
+
+ public static void inflateMenu(T activity, Menu menu) {
+ activity.getMenuInflater().inflate(R.menu.toolbar_apptheme, menu);
+ }
+
+ public static void inflateCompatMenu(T activity, Menu menu) {
+ activity.getMenuInflater().inflate(R.menu.toolbar_apptheme, menu);
+ }
+
+ /*public static void inflateWinBoLLMenu(T activity, Menu menu) {
+ activity.getMenuInflater().inflate(R.menu.toolbar_apptheme, menu);
+ }*/
+
+ public static boolean onAppThemeItemSelected(T activity, MenuItem item) {
+ int nThemeStyleID;
+ if (R.id.item_depththeme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.DEPTH);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_skytheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.SKY);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_goldentheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.GOLDEN);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_bearingtheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.BEARING);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_memortheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.MEMOR);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_taotheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.TAO);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_defaulttheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.AES);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static boolean onAppCompatThemeItemSelected(T activity, MenuItem item) {
+ int nThemeStyleID;
+ if (R.id.item_depththeme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.DEPTH);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_skytheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.SKY);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_goldentheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.GOLDEN);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_bearingtheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.BEARING);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_memortheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.MEMOR);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_taotheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.TAO);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ } else if (R.id.item_defaulttheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.AES);
+ saveThemeStyleID(activity, nThemeStyleID);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static boolean onWinBoLLThemeItemSelected(T activity, MenuItem item) {
+ int nThemeStyleID;
+ if (R.id.item_depththeme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.DEPTH);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_skytheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.SKY);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_goldentheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.GOLDEN);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_bearingtheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.BEARING);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_memortheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.MEMOR);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_taotheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.TAO);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_defaulttheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.AES);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static boolean onWinBoLLThemeItemSelected(T activity, MenuItem item) {
+ int nThemeStyleID;
+ if (R.id.item_depththeme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.DEPTH);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_skytheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.SKY);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_goldentheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.GOLDEN);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_bearingtheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.BEARING);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_memortheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.MEMOR);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_taotheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.TAO);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ } else if (R.id.item_defaulttheme == item.getItemId()) {
+ nThemeStyleID = AESThemeBean.getThemeStyleID(AESThemeBean.ThemeType.AES);
+ saveThemeStyleID(activity.getApplicationContext(), nThemeStyleID);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/AppVersionUtils.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/AppVersionUtils.java
new file mode 100644
index 0000000..e4ac66b
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/AppVersionUtils.java
@@ -0,0 +1,162 @@
+package cc.winboll.studio.libaes.utils;
+
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/08/12 14:45:35
+ * @Describe 应用版本工具集
+ */
+import cc.winboll.studio.libappbase.LogUtils;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class AppVersionUtils {
+
+ public static final String TAG = "AppVersionUtils";
+
+ //
+ // 检查新版本是否成立
+ // szCurrentCode : 当前版本应用包名
+ // szNextCode : 新版本应用包名
+ // 返回 :情况1:当前版本是发布版
+ // 返回 true (新版本 > 当前版本)
+ // 情况1:当前版本是Beta版
+ // true 新版本 == 当前版本
+ //
+ public static boolean isHasNewVersion2(String szCurrentName, String szNextName) {
+ LogUtils.d(TAG, String.format("isHasNewVersion2\nszCurrentName : %s\nszNextName : %s", szCurrentName, szNextName));
+ //szCurrentName = "AES_6.2.0-beta0_3234.apk";
+ //szNextName = "AES_6.1.12.apk";
+ //szCurrentName = "AES_6.2.0-beta0_3234.apk";
+ //szNextName = "AES_6.2.0.apk";
+ //szCurrentName = "AES_6.2.0-beta0_3234.apk";
+ //szNextName = "AES_6.2.2.apk";
+ //szCurrentName = "AES_6.2.0-beta0_3234.apk";
+ //szNextName = "AES_6.2.0.apk";
+ //szCurrentName = "AES_6.1.0.apk";
+ //szNextName = "AES_6.2.0.apk";
+ //LogUtils.d(TAG, "szCurrentName : " + szCurrentName);
+ //LogUtils.d(TAG, "szNextName : " + szNextName);
+
+ //boolean isVersionNewer = false;
+ //if(szCurrentName.equals(szNextName)) {
+ // isVersionNewer = false;
+ //} else {
+ //ToastUtils.show("szCurrent : " + szCurrent + "\nszNext : " + szNext);
+ //int nApk = szNextName.lastIndexOf(".apk");
+ //ToastUtils.show("nApk : " + Integer.toString(nApk));
+ //String szNextNoApkName = szNextName.substring(0, nApk);
+ //ToastUtils.show("szNextNoApkName : " + szNextNoApkName);
+ //String szCurrentNoApkName = szCurrentName.substring(0, szNextNoApkName.length());
+ //ToastUtils.show("szCurrentNoApkName : " + szCurrentNoApkName);
+ //String str1 = "3.4.50";
+ //String str2 = "3.3.60";
+ //String str1 = getCodeInPackageName(szCurrentName);
+ //String str2 = getCodeInPackageName(szNextName);
+ //String str1 = getCodeInPackageName(szNextName);
+ //String str2 = getCodeInPackageName(szCurrentName);
+ //Boolean isVersionNewer2 = checkNewVersion(str1,str2);
+ //ToastUtils.show("isVersionNewer2 : " + Boolean.toString(isVersionNewer2));
+ //ToastUtils.show(checkNewVersion(getCodeInPackageName(szCurrentName), getCodeInPackageName(szNextName)));
+ //return checkNewVersion(getCodeInPackageName(szCurrentName), getCodeInPackageName(szNextName));
+ //}
+ //return isVersionNewer;
+ if (checkNewVersion(getCodeInPackageName(szCurrentName), getCodeInPackageName(szNextName))) {
+ // 比 AES_6.2.0.apk 版本大,如 AES_6.2.1.apk。
+ // 比 AES_6.2.0-beta0_3234.apk 大,如 AES_6.2.1.apk。
+ //LogUtils.d(TAG, "App newer stage version is released. Release name : " + szNextName);
+ return true;
+ }
+ if (szCurrentName.matches(".*_\\d+\\.\\d+\\.\\d+-beta.*\\.apk")) {
+ String szCurrentReleasePackageName = getReleasePackageName(szCurrentName);
+ //LogUtils.d(TAG, "szCurrentReleasePackageName : " + szCurrentReleasePackageName);
+ if (szCurrentReleasePackageName.equals(szNextName)) {
+ // 与 AES_6.2.0-beta0_3234.apk 版本相同,如 AES_6.2.0.apk。
+ //LogUtils.d(TAG, "App stage version is released. Release name : " + szNextName);
+ return true;
+ }
+ }
+ //LogUtils.d(TAG, "App version is the newest. ");
+ return false;
+ }
+
+ public static boolean isHasNewStageReleaseVersion(String szCurrentName, String szNextName) {
+ LogUtils.d(TAG, String.format("isHasNewStageReleaseVersion\nszCurrentName : %s\nszNextName : %s", szCurrentName, szNextName));
+ //szCurrentName = "AES_6.2.12.apk";
+ //szNextName = "AES_6.3.12.apk";
+ if (checkNewVersion(getCodeInPackageName(szCurrentName), getCodeInPackageName(szNextName))) {
+ // 比 AES_6.2.0.apk 版本大,如 AES_6.2.1.apk。
+ //LogUtils.d(TAG, "App newer stage version is released. Release name : " + szNextName);
+ return true;
+ }
+ return false;
+ }
+
+ //
+ // 检查新版本是否成立
+ // szCurrentCode : 当前版本
+ // szNextCode : 新版本
+ // 返回 :true 新版本 > 当前版本
+ //
+ public static Boolean checkNewVersion(String szCurrentCode, String szNextCode) {
+ if (szCurrentCode == null || szCurrentCode.equals("") || szNextCode == null || szNextCode.equals("")) {
+ LogUtils.d(TAG, String.format("checkNewVersion unexpected parameters:\nszCurrentCode : %s\nszNextCode : %s", szCurrentCode, szNextCode));
+ return false;
+ }
+ boolean isNew = false;
+ String[] appVersionCurrent = szCurrentCode.split("\\.");
+ String[] appVersionNext = szNextCode.split("\\.");
+ //根据位数最短的判断
+ int lim = appVersionCurrent.length > appVersionNext.length ? appVersionNext.length : appVersionCurrent.length;
+ //根据位数循环判断各个版本
+ for (int i = 0; i < lim; i++) {
+ if (Integer.parseInt(appVersionNext[i]) > Integer.parseInt(appVersionCurrent[i])) {
+ isNew = true;
+ return isNew;
+ } else if (Integer.parseInt(appVersionNext[i]) == Integer.parseInt(appVersionCurrent[i])) {
+ continue ;
+ } else {
+ isNew = false;
+ return isNew;
+ }
+ }
+ return isNew;
+ }
+
+ //
+ // 截取应用包名称版本号信息
+ // 如 :AppUtils_7.0.4-beta1_0120.apk 版本号为 7.0.4
+ // 如 :AppUtils_7.0.4.apk 版本号为 7.0.4
+ //
+ public static String getCodeInPackageName(String apkName) {
+ LogUtils.d(TAG, String.format("getCodeInPackageName apkName : %s", apkName));
+ //String apkName = "AppUtils_7.0.0.apk";
+ Pattern pattern = Pattern.compile("\\d+\\.\\d+\\.\\d+");
+ Matcher matcher = pattern.matcher(apkName);
+ if (matcher.find()) {
+ String version = matcher.group();
+ LogUtils.d(TAG, String.format("version is %s", version));
+ return version;
+ //System.out.println("Version number: " + version); // 输出:7.0.0
+ }
+ LogUtils.d(TAG, String.format("No result."));
+ return "";
+ }
+
+ //
+ // 根据Beta版名称生成发布版应用包名称
+ // 如 AppUtils_7.0.4-beta1_0120.apk
+ // 发布版名称就为AppUtils_7.0.4.apk
+ //
+ public static String getReleasePackageName(String szBetaPackageName) {
+ //String szBetaPackageName = "AppUtils_7.0.4-beta1_0120.apk";
+ Pattern pattern = Pattern.compile(".*\\d+\\.\\d+\\.\\d+");
+ Matcher matcher = pattern.matcher(szBetaPackageName);
+ if (matcher.find()) {
+ String szReleasePackageName = matcher.group();
+ return szReleasePackageName + ".apk";
+ //System.out.println("Version number: " + version); // 输出:7.0.0
+ }
+ return "";
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/DevelopUtils.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/DevelopUtils.java
new file mode 100644
index 0000000..377afc3
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/DevelopUtils.java
@@ -0,0 +1,35 @@
+package cc.winboll.studio.libaes.utils;
+
+import android.app.Activity;
+import android.view.Menu;
+import android.view.MenuItem;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libappbase.LogActivity;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/12/07 13:31
+ * @Describe 应用开发工具类
+ */
+public class DevelopUtils {
+
+ public static final String TAG = "DevelopUtils";
+
+ public static void inflateMenu(T activity, Menu menu) {
+ activity.getMenuInflater().inflate(R.menu.toolbar_appdebug, menu);
+ }
+
+ public static boolean onDevelopItemSelected(T activity, MenuItem item) {
+ if (R.id.item_testappcrash == item.getItemId()) {
+ for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
+ activity.getString(i);
+ }
+ } else if (R.id.item_log == item.getItemId()) {
+ //ToastUtils.show("Test");
+ LogActivity.startLogActivity(activity);
+ } else {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/MimoUtils.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/MimoUtils.java
new file mode 100644
index 0000000..2c07337
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/MimoUtils.java
@@ -0,0 +1,33 @@
+package cc.winboll.studio.libaes.utils;
+
+import android.content.Context;
+import android.util.DisplayMetrics;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/18 15:23
+ * @Describe 米盟 MimoUtils
+ */
+public final class MimoUtils {
+ public static final String TAG = "Utils";
+
+ public static int dpToPx(Context context, float dp) {
+ DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+ return (int) (dp * displayMetrics.density + 0.5f);
+ }
+
+ public static int pxToDp(Context context, float px) {
+ DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+ return (int) (px / displayMetrics.density + 0.5f);
+ }
+
+ public static int pxToSp(Context context, float pxValue) {
+ DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+ return (int) (pxValue / displayMetrics.scaledDensity + 0.5f);
+ }
+
+ public static int spToPx(Context context, float spValue) {
+ DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+ return (int) (spValue * displayMetrics.scaledDensity + 0.5f);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/MyActivityLifecycleCallbacks.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/MyActivityLifecycleCallbacks.java
new file mode 100644
index 0000000..d32928d
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/MyActivityLifecycleCallbacks.java
@@ -0,0 +1,97 @@
+package cc.winboll.studio.libaes.utils;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/03/25 04:29:19
+ */
+import android.app.Activity;
+import android.app.Application;
+import android.content.Intent;
+import android.os.Bundle;
+import cc.winboll.studio.libappbase.LogUtils;
+
+public class MyActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
+
+ public static final String TAG = "MyActivityLifecycleCallbacks";
+
+ public String mInfo = "";
+
+ public MyActivityLifecycleCallbacks() {
+
+ }
+
+ void createActivityeInfo(Activity activity) {
+ StringBuilder sb = new StringBuilder();
+ Intent receivedIntent = activity.getIntent();
+ sb.append("\nCallingActivity : \n");
+ if (activity.getCallingActivity() != null) {
+ sb.append(activity.getCallingActivity().getPackageName());
+ }
+ sb.append("\nReceived Intent Package : \n");
+ sb.append(receivedIntent.getPackage());
+
+ Bundle extras = receivedIntent.getExtras();
+ if (extras != null) {
+ for (String key : extras.keySet()) {
+ sb.append("\nIntentInfo");
+ sb.append("\n键: ");
+ sb.append(key);
+ sb.append(", 值: ");
+ sb.append(extras.get(key));
+ //Log.d("IntentInfo", "键: " + key + ", 值: " + extras.get(key));
+ }
+ }
+ mInfo = sb.toString();
+ //Log.d("IntentInfo", "发送Intent的应用包名: " + senderPackage);
+ }
+
+ public void showActivityeInfo() {
+ //ToastUtils.show("ActivityeInfo : " + mInfo);
+ LogUtils.d(TAG, "ActivityeInfo : " + mInfo);
+ }
+
+ @Override
+ public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
+ // 在这里可以做一些初始化相关的操作,例如记录Activity的创建时间等
+ //System.out.println(activity.getLocalClassName() + " was created");
+ LogUtils.d(TAG, activity.getLocalClassName() + " was created");
+ createActivityeInfo(activity);
+ }
+
+ @Override
+ public void onActivityStarted(Activity activity) {
+ //System.out.println(activity.getLocalClassName() + " was started");
+ LogUtils.d(TAG, activity.getLocalClassName() + " was started");
+ //createActivityeInfo(activity);
+ }
+
+ @Override
+ public void onActivityResumed(Activity activity) {
+ //System.out.println(activity.getLocalClassName() + " was resumed");
+ LogUtils.d(TAG, activity.getLocalClassName() + " was resumed");
+ //createActivityeInfo(activity);
+ }
+
+ @Override
+ public void onActivityPaused(Activity activity) {
+ //System.out.println(activity.getLocalClassName() + " was paused");
+ LogUtils.d(TAG, activity.getLocalClassName() + " was paused");
+ }
+
+ @Override
+ public void onActivityStopped(Activity activity) {
+ //System.out.println(activity.getLocalClassName() + " was stopped");
+ LogUtils.d(TAG, activity.getLocalClassName() + " was stopped");
+ }
+
+ @Override
+ public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
+ // 可以在这里添加保存状态的自定义逻辑
+ }
+
+ @Override
+ public void onActivityDestroyed(Activity activity) {
+ //System.out.println(activity.getLocalClassName() + " was destroyed");
+ LogUtils.d(TAG, activity.getLocalClassName() + " was destroyed");
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/PrefUtils.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/PrefUtils.java
new file mode 100644
index 0000000..751a9e5
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/PrefUtils.java
@@ -0,0 +1,33 @@
+package cc.winboll.studio.libaes.utils;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/13 06:50
+ * @Describe 应用变量保存工具
+ */
+
+public class PrefUtils {
+
+ public static final String TAG = "PrefUtils";
+
+ //
+ // 保存字符串到SharedPreferences的函数
+ //
+ public static void saveString(Context context, String key, String value) {
+ SharedPreferences sharedPreferences = context.getSharedPreferences("myPrefs", Context.MODE_PRIVATE);
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putString(key, value);
+ editor.apply();
+ }
+
+ //
+ // 从SharedPreferences读取字符串的函数
+ //
+ public static String getString(Context context, String key, String defaultValue) {
+ SharedPreferences sharedPreferences = context.getSharedPreferences("myPrefs", Context.MODE_PRIVATE);
+ return sharedPreferences.getString(key, defaultValue);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/ScreenUtil.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/ScreenUtil.java
new file mode 100644
index 0000000..20399ea
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/ScreenUtil.java
@@ -0,0 +1,64 @@
+package cc.winboll.studio.libaes.utils;
+
+import android.content.Context;
+import android.util.DisplayMetrics;
+import android.view.WindowManager;
+
+public class ScreenUtil {
+
+ // 分辨率宽度和高度计量类
+ //
+ public static class ScreenSize {
+ int widthPixels;
+ int heightPixels;
+
+ public ScreenSize(int widthPixels, int heightPixels) {
+ this.widthPixels = widthPixels;
+ this.heightPixels = heightPixels;
+ }
+
+ public void setWidthPixels(int widthPixels) {
+ this.widthPixels = widthPixels;
+ }
+
+ public int getWidthPixels() {
+ return widthPixels;
+ }
+
+ public void setHeightPixels(int heightPixels) {
+ this.heightPixels = heightPixels;
+ }
+
+ public int getHeightPixels() {
+ return heightPixels;
+ }
+ }
+
+ // 获取屏幕分辨率宽度和高度
+ //
+ public static ScreenSize getScreenSize(Context mContext) {
+ WindowManager manager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
+ DisplayMetrics dm = new DisplayMetrics();
+ manager.getDefaultDisplay().getMetrics(dm);
+ return new ScreenSize(dm.widthPixels, dm.heightPixels);
+ }
+
+ // 获取屏幕宽度
+ //
+ public static int getScreenWidth(Context mContext) {
+ WindowManager manager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
+ DisplayMetrics dm = new DisplayMetrics();
+ manager.getDefaultDisplay().getMetrics(dm);
+ return dm.widthPixels;
+ }
+
+ // 获取屏幕高度
+ //
+ public static int getScreenHeight(Context mContext) {
+ WindowManager manager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
+ DisplayMetrics dm = new DisplayMetrics();
+ manager.getDefaultDisplay().getMetrics(dm);
+ return dm.heightPixels;
+ }
+
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/WebUtils.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/WebUtils.java
new file mode 100644
index 0000000..94d8502
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/WebUtils.java
@@ -0,0 +1,102 @@
+package cc.winboll.studio.libaes.utils;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.widget.Toast;
+
+import cc.winboll.studio.libappbase.LogUtils;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/05 15:45
+ * @LastEditTime 2026/01/05 19:30:00 HKT
+ * @Describe 网页工具集(优化:新增合法性校验+浏览器可用性检查+链接格式自动修复)
+ */
+public class WebUtils {
+
+ public static final String TAG = "WebUtils";
+
+ /**
+ * 唤起系统默认浏览器打开指定网站
+ * @param context 上下文对象(建议使用 ApplicationContext 避免内存泄漏)
+ * @param url 目标 URL(支持自动修复格式错误)
+ */
+ public static void openUrlInBrowser(Context context, String url) {
+ // 1. 空指针与合法性校验
+ if (context == null) {
+ LogUtils.e(TAG, "openUrlInBrowser: Context is null");
+ return;
+ }
+ if (url == null || url.trim().isEmpty()) {
+ LogUtils.e(TAG, "openUrlInBrowser: Url is null or empty");
+ showToast(context, "链接不能为空");
+ return;
+ }
+
+ // 2. 链接格式自动修复(核心新增:处理多斜杠、补全协议头)
+ String fixedUrl = fixUrlFormat(url.trim());
+ LogUtils.d(TAG, "openUrlInBrowser: Fixed url from [" + url + "] to [" + fixedUrl + "]");
+
+ // 3. 构建隐式意图
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(fixedUrl));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // 新任务栈启动
+
+ // 4. 检查浏览器可用性
+ if (intent.resolveActivity(context.getPackageManager()) != null) {
+ try {
+ context.startActivity(intent);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "openUrlInBrowser: Start activity failed", e);
+ showToast(context, "打开浏览器失败,请手动复制链接");
+ }
+ } else {
+ LogUtils.e(TAG, "openUrlInBrowser: No browser app found");
+ showToast(context, "未找到可用的浏览器应用");
+ }
+ }
+
+ /**
+ * 工具方法:修复 URL 格式错误
+ * 1. 补全 http/https 协议头
+ * 2. 处理协议头后的多斜杠问题(如 https://mmec//path → https://mmec/path)
+ * @param originalUrl 原始 URL
+ * @return 修复后的 URL
+ */
+ private static String fixUrlFormat(String originalUrl) {
+ String url = originalUrl;
+
+ // 步骤1:补全协议头(优先 https)
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ url = "https://" + url;
+ }
+
+ // 步骤2:修复协议头后的多斜杠问题
+ // 匹配 https:// 或 http:// 后的任意数量斜杠,替换为单斜杠
+ url = url.replaceAll("(?<=https?://)[/]+", "/");
+
+ return url;
+ }
+
+ /**
+ * 工具方法:显示 Toast 提示(确保在主线程执行)
+ */
+ private static void showToast(final Context context, final String message) {
+ if (context == null || message == null) {
+ return;
+ }
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ Toast.makeText(context.getApplicationContext(), message, Toast.LENGTH_SHORT).show();
+ } else {
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(context.getApplicationContext(), message, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+ }
+}
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/utils/WinBoLLActivityManager.java b/libaes/src/main/java/cc/winboll/studio/libaes/utils/WinBoLLActivityManager.java
new file mode 100644
index 0000000..cac2157
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/utils/WinBoLLActivityManager.java
@@ -0,0 +1,306 @@
+package cc.winboll.studio.libaes.utils;
+
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
+import cc.winboll.studio.libappbase.GlobalApplication;
+import cc.winboll.studio.libappbase.LogActivity;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.ToastUtils;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/05/10 10:02
+ * @Describe 应用活动窗口管理器(改进版)
+ * 核心能力:多任务窗口管理、Activity栈维护、任务前台恢复、批量关闭、前后Activity切换
+ * 参考 :android 类似微信小程序多任务窗口 及 设置 TaskDescription 修改 icon 和 label
+ */
+public class WinBoLLActivityManager {
+
+ public static final String TAG = "WinBoLLActivityManager";
+ public static final String EXTRA_TAG = "EXTRA_TAG";
+
+ public enum WinBoLLUI_TYPE { APPLICATION, SERVICE } // 规范命名 大写开头
+
+ private GlobalApplication mGlobalApplication;
+ private static volatile WinBoLLActivityManager sInstance; // 单例命名规范
+ private final Map mActivityListMap; // 私有不可变
+ private static volatile WinBoLLUI_TYPE sWinBoLLUI_TYPE = WinBoLLUI_TYPE.SERVICE;
+
+ // 私有构造 杜绝外部实例化
+ private WinBoLLActivityManager(@NonNull GlobalApplication application) {
+ mGlobalApplication = application;
+ mActivityListMap = new HashMap<>(); // 菱形泛型简化
+ }
+
+ /**
+ * 初始化管理器(必须在Application onCreate中调用)
+ */
+ public static void init(@NonNull T application) {
+ if (sInstance == null) {
+ synchronized (WinBoLLActivityManager.class) {
+ if (sInstance == null) {
+ sInstance = new WinBoLLActivityManager(application);
+ }
+ }
+ }
+ }
+
+ /**
+ * 获取单例(需先调用init初始化,否则抛异常)
+ */
+ @NonNull
+ public static WinBoLLActivityManager getInstance() {
+ if (sInstance == null) {
+ throw new IllegalStateException("WinBoLLActivityManager 未初始化,请先在Application中调用 init()");
+ }
+ return sInstance;
+ }
+
+ // ===================== 基础配置 =====================
+ public static void setWinBoLLUI_TYPE(@NonNull WinBoLLUI_TYPE winBoLLUI_TYPE) {
+ sWinBoLLUI_TYPE = winBoLLUI_TYPE;
+ }
+
+ @NonNull
+ public static WinBoLLUI_TYPE getWinBoLLUI_TYPE() {
+ return sWinBoLLUI_TYPE;
+ }
+
+ // ===================== Activity 增删查 =====================
+ /**
+ * 把Activity添加到管理中(自动去重)
+ */
+ public void add(@NonNull T activity) {
+ String tag = activity.getTag();
+ if (isActivityActive(tag)) {
+ LogUtils.d(TAG, String.format("Activity[%s] 已处于活跃状态,无需重复添加", tag));
+ return;
+ }
+ mActivityListMap.put(tag, activity);
+ LogUtils.d(TAG, String.format("添加Activity:%s,当前管理数量:%d", tag, mActivityListMap.size()));
+ }
+
+ /**
+ * 判断指定Tag的Activity是否活跃
+ */
+ public boolean isActivityActive(@NonNull String tag) {
+ return mActivityListMap.containsKey(tag) && mActivityListMap.get(tag) != null;
+ }
+
+ /**
+ * 根据Tag获取Activity(空安全)
+ */
+ @Nullable
+ public Activity getActivityByTag(@NonNull String tag) {
+ IWinBoLLActivity winBoLLActivity = mActivityListMap.get(tag);
+ if (winBoLLActivity == null) return null;
+ Activity activity = winBoLLActivity.getActivity();
+ // 过滤已销毁/已结束的Activity
+ if (activity == null || activity.isFinishing() || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && activity.isDestroyed())) {
+ registeRemove(winBoLLActivity);
+ return null;
+ }
+ return activity;
+ }
+
+ /**
+ * 移除指定Activity(销毁时调用)
+ */
+ public boolean registeRemove(@NonNull T iWinBoLLActivity) {
+ String tag = iWinBoLLActivity.getTag();
+ if (mActivityListMap.containsKey(tag)) {
+ mActivityListMap.remove(tag);
+ LogUtils.d(TAG, String.format("移除Activity:%s,剩余管理数量:%d", tag, mActivityListMap.size()));
+ return true;
+ }
+ return false;
+ }
+
+ // ===================== Activity 启动 =====================
+ /**
+ * 启动WinBoLLActivity(存在则前台恢复,不存在则新建多任务窗口)
+ */
+ public void startWinBoLLActivity(@NonNull Context context, @NonNull Class clazz) {
+ if (!resumeActivity(clazz)) {
+ Intent intent = new Intent(context, clazz);
+ setMultiTaskFlags(intent);
+ context.startActivity(intent);
+ }
+ }
+
+ /**
+ * 带Intent参数启动WinBoLLActivity
+ */
+ public void startWinBoLLActivity(@NonNull Context context, @NonNull Intent intent, @NonNull Class clazz) {
+ if (!resumeActivity(clazz)) {
+ setMultiTaskFlags(intent);
+ context.startActivity(intent);
+ }
+ }
+
+ /**
+ * 启动日志页面(固定多任务模式)
+ */
+ public void startLogActivity(@NonNull Context context) {
+ Intent intent = new Intent(context, LogActivity.class);
+ setMultiTaskFlags(intent);
+ intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); // 分屏相关
+ context.startActivity(intent);
+ }
+
+ /**
+ * 设置多任务窗口通用Flags
+ */
+ private void setMultiTaskFlags(@NonNull Intent intent) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
+ }
+
+ // ===================== Activity 前台恢复 =====================
+ /**
+ * 根据Activity类 恢复前台(反射获取Tag,需保证无参构造)
+ */
+ public boolean resumeActivity(@NonNull Class clazz) {
+ try {
+ T instance = clazz.newInstance();
+ return resumeActivity(instance.getTag());
+ } catch (InstantiationException | IllegalAccessException e) {
+ LogUtils.e(TAG, "恢复Activity失败,类需提供无参构造", e);
+ }
+ return false;
+ }
+
+ /**
+ * 根据Tag 恢复Activity前台
+ */
+ public boolean resumeActivity(@NonNull String tag) {
+ Activity activity = getActivityByTag(tag);
+ return activity != null && resumeActivity(activity);
+ }
+
+ /**
+ * 恢复指定Activity到前台(适配高版本权限)
+ */
+ @SuppressWarnings("deprecation")
+ public boolean resumeActivity(@NonNull Activity activity) {
+ if (activity.isFinishing() || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && activity.isDestroyed())) {
+ return false;
+ }
+ try {
+ ActivityManager am = (ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
+ if (am == null) {
+ LogUtils.w(TAG, "获取ActivityManager失败,无法恢复前台");
+ return false;
+ }
+ // Android 11+ 限制,低版本正常使用
+ am.moveTaskToFront(activity.getTaskId(), ActivityManager.MOVE_TASK_NO_USER_ACTION);
+ //ToastUtils.show(String.format("Activity[%s] 已恢复到前台", activity.getClass().getSimpleName()));
+ LogUtils.d(TAG, String.format("Activity[%s] 已恢复到前台", activity.getClass().getSimpleName()));
+ return true;
+ } catch (SecurityException e) {
+ //ToastUtils.show("恢复Activity前台失败,缺少权限或系统限制 :" + e.getMessage());
+ LogUtils.e(TAG, "恢复Activity前台失败,缺少权限或系统限制", e);
+ //ToastUtils.show("窗口恢复失败,请手动打开");
+ return false;
+ }
+ }
+
+ // ===================== Activity 关闭 =====================
+ /**
+ * 结束所有管理的Activity(按UI类型选择关闭策略)
+ */
+ public void finishAll() {
+ if (mActivityListMap.isEmpty()) {
+ LogUtils.d(TAG, "当前无管理的Activity,无需结束");
+ return;
+ }
+ LogUtils.d(TAG, String.format("开始结束所有Activity,共%d个", mActivityListMap.size()));
+ Iterator> iterator = mActivityListMap.entrySet().iterator();
+ while (iterator.hasNext()) {
+ IWinBoLLActivity winBoLLActivity = iterator.next().getValue();
+ Activity activity = winBoLLActivity.getActivity();
+ if (activity == null) {
+ iterator.remove();
+ continue;
+ }
+ // 安全关闭,避免重复操作
+ if (!activity.isFinishing() && !(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && activity.isDestroyed())) {
+ if (sWinBoLLUI_TYPE == WinBoLLUI_TYPE.SERVICE) {
+ activity.finishAndRemoveTask(); // 结束+移除最近任务
+ } else if (sWinBoLLUI_TYPE == WinBoLLUI_TYPE.APPLICATION) {
+ activity.finish(); // 仅结束页面
+ }
+ }
+ iterator.remove(); // 移除已处理的项
+ }
+ LogUtils.d(TAG, "所有Activity结束完成");
+ }
+
+ /**
+ * 结束指定Activity,自动恢复上一个Activity前台
+ */
+ public void finish(@NonNull T iWinBoLLActivity) {
+ Activity currentActivity = iWinBoLLActivity.getActivity();
+ if (currentActivity == null || currentActivity.isFinishing() || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && currentActivity.isDestroyed())) {
+ registeRemove(iWinBoLLActivity);
+ return;
+ }
+
+ // 先获取上一个Activity,再关闭当前
+ Activity preActivity = getPreActivity(iWinBoLLActivity);
+ currentActivity.finish();
+ registeRemove(iWinBoLLActivity); // 关闭后移除管理
+
+ // 恢复上一个Activity前台
+ if (preActivity != null) {
+ resumeActivity(preActivity);
+ }
+ }
+
+ /**
+ * 获取当前Activity的上一个栈内Activity(修复原遍历逻辑错误)
+ */
+ @Nullable
+ private Activity getPreActivity(@NonNull IWinBoLLActivity currentActivity) {
+ String currentTag = currentActivity.getTag();
+ IWinBoLLActivity preWinBoLLActivity = null;
+ for (Map.Entry entry : mActivityListMap.entrySet()) {
+ String tag = entry.getKey();
+ if (Objects.equals(tag, currentTag)) {
+ break; // 找到当前Activity,循环终止,pre即为上一个
+ }
+ preWinBoLLActivity = entry.getValue();
+ }
+ return preWinBoLLActivity != null ? preWinBoLLActivity.getActivity() : null;
+ }
+
+ // ===================== 调试辅助 =====================
+ /**
+ * 打印所有管理的Activity信息(调试用)
+ */
+ public void printActivityListInfo() {
+ if (mActivityListMap.isEmpty()) {
+ LogUtils.d(TAG, "当前管理的Activity列表为空");
+ return;
+ }
+ StringBuilder sb = new StringBuilder(String.format("Activity管理列表(总数:%d)\n", mActivityListMap.size()));
+ for (Map.Entry entry : mActivityListMap.entrySet()) {
+ sb.append("Tag: ").append(entry.getKey())
+ .append(" | Activity: ").append(entry.getValue().getActivity().getClass().getSimpleName())
+ .append("\n");
+ }
+ LogUtils.d(TAG, sb.toString());
+ }
+}
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/AButton.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/AButton.java
new file mode 100644
index 0000000..5c9c111
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/AButton.java
@@ -0,0 +1,28 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:41:22
+ * @Describe AButton
+ */
+import android.content.Context;
+import android.util.AttributeSet;
+import cc.winboll.studio.libaes.R;
+
+public class AButton extends android.widget.Button {
+
+ public static final String TAG = "AButton";
+
+ public AButton(Context context) {
+ super(context);
+ }
+
+ public AButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setBackground(context.getDrawable(R.drawable.btn_style));
+ }
+
+ public AButton(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/ACard.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/ACard.java
new file mode 100644
index 0000000..4f627f8
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/ACard.java
@@ -0,0 +1,45 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:44:27
+ * @Describe ACard
+ */
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+import cc.winboll.studio.libaes.R;
+
+public class ACard extends LinearLayout {
+
+ public static final String TAG = "ACard";
+
+ public ACard(Context context) {
+ super(context);
+ }
+
+ public ACard(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setPadding(0 + 0 + 2 + 1, 0 + 0 + 2 + 1, 0 + 1 + 3 + 1, 0 + 2 + 3 + 1);
+
+ // 获得TypedArray
+ //TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AToolbar);
+ // 获得attrs.xml里面的属性值,格式为:名称_属性名,后面是默认值
+ //int colorBackgroud = a.getColor(R.styleable.ACard_backgroudColor, context.getColor(R.color.colorACardBackgroung));
+ //int centerColor = a.getColor(R.styleable.AToolbar_centerColor, context.getColor(R.color.colorAToolbarCenterColor));
+ //int endColor = a.getColor(R.styleable.AToolbar_endColor, context.getColor(R.color.colorAToolbarEndColor));
+ //float tSize = a.getDimension(R.styleable.CustomView_tSize, 35);
+ //p.setColor(tColor);
+ //p.setTextSize(tSize);
+ //Drawable drawable = context.getDrawable(R.drawable.frame_atoolbar);
+
+ setBackground(context.getDrawable(R.drawable.acard_frame_main));
+
+ // 返回一个绑定资源结束的信号给资源
+ //a.recycle();
+ }
+
+ public ACard(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/ADrawerMenuListView.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/ADrawerMenuListView.java
new file mode 100644
index 0000000..2f4e889
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/ADrawerMenuListView.java
@@ -0,0 +1,19 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2023/05/30 11:30:07
+ */
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ListView;
+
+public class ADrawerMenuListView extends ListView {
+
+ public static final String TAG = "ADrawerMenuListView";
+
+ public ADrawerMenuListView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/ADsBannerView.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/ADsBannerView.java
new file mode 100644
index 0000000..89eb74f
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/ADsBannerView.java
@@ -0,0 +1,491 @@
+package cc.winboll.studio.libaes.views;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.enums.ADsMode;
+import cc.winboll.studio.libaes.utils.MimoUtils;
+import cc.winboll.studio.libappbase.GlobalApplication;
+import cc.winboll.studio.libappbase.LogUtils;
+import com.miui.zeus.mimo.sdk.ADParams;
+import com.miui.zeus.mimo.sdk.BannerAd;
+import com.miui.zeus.mimo.sdk.MimoCustomController;
+import com.miui.zeus.mimo.sdk.MimoLocation;
+import com.miui.zeus.mimo.sdk.MimoSdk;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/18 14:41
+ * @Describe WinBoLL 横幅广告类
+ */
+public class ADsBannerView extends LinearLayout {
+
+ public static final String TAG = "ADsBannerView";
+
+
+ private String BANNER_POS_ID = "802e356f1726f9ff39c69308bfd6f06a";
+ private String BANNER_POS_ID_WINBOLL_BETA = "d129ee5a263911f981a6dc7a9802e3e7";
+ private String BANNER_POS_ID_WINBOLL = "4ec30efdb32271765b9a4efac902828b";
+
+ /*
+ private String BANNER_POS_ID = "802e356f1726f9ff39c69308bfd6f06a";
+ private String BANNER_POS_ID_WINBOLL_BETA = "802e356f1726f9ff39c69308bfd6f06a";
+ private String BANNER_POS_ID_WINBOLL = "802e356f1726f9ff39c69308bfd6f06a";
+ */
+
+ Context mContext;
+ View mMianView;
+ SharedPreferences mSharedPreferences;
+ ViewGroup mContainer;
+ BannerAd mBannerAd;
+ List mAllBanners = new ArrayList<>();
+ // 新增:主线程Handler,确保广告操作在主线程执行
+ private Handler mMainHandler;
+
+ public ADsBannerView(Context context) {
+ super(context);
+ initView(context);
+ }
+
+ public ADsBannerView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initView(context);
+ }
+
+ public ADsBannerView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initView(context);
+ }
+
+ public ADsBannerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ initView(context);
+ }
+
+ void initView(Context context) {
+ this.mContext = context;
+ initMimoSdk(this.mContext);
+
+ // 初始化主线程Handler(关键:确保广告操作在主线程执行)
+ mMainHandler = new Handler(Looper.getMainLooper());
+
+ this.mMianView = inflate(this.mContext, R.layout.view_adsbanner, null);
+ mContainer = this.mMianView.findViewById(R.id.ads_container);
+ addView(this.mMianView);
+ }
+
+ public void resumeADs(final Activity activity) {
+ // 没有设置米盟广告支持就退出
+ if (ADsControlView.getAdsModeFromStatic(this.mContext) != ADsMode.MIMO_SDK) {
+ // 2. 释放之前的广告资源
+ if (mBannerAd != null) {
+ mBannerAd.destroy();
+ }
+ return;
+ }
+
+ // 修复:优化广告请求逻辑(添加生命周期判断 + 主线程执行)
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
+ if (ADsControlView.getAdsModeFromStatic(this.mContext) == ADsMode.MIMO_SDK) {
+ LogUtils.i(TAG, "已设置播放米盟广告,正在播放...");
+ mMainHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ // 再次校验生命周期,避免延迟执行时Activity已销毁
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
+ fetchAd(activity);
+ }
+ }
+ }, 1000); // 延迟1秒请求广告,提升页面加载体验
+ }
+ }
+ }
+
+ /**
+ * 释放广告资源(关键:避免内存泄漏和空Context调用)
+ */
+ public void releaseAdResources() {
+ // 没有设置米盟广告支持就退出
+ if (ADsControlView.getAdsModeFromStatic(this.mContext) != ADsMode.MIMO_SDK) {
+ return;
+ }
+
+ LogUtils.d(TAG, "releaseAdResources()");
+
+ // 移除Handler回调
+ if (mMainHandler != null) {
+ mMainHandler.removeCallbacksAndMessages(null);
+ }
+
+ // 销毁所有广告实例
+ if (mAllBanners != null && !mAllBanners.isEmpty()) {
+ for (BannerAd ad : mAllBanners) {
+ if (ad != null) {
+ ad.destroy();
+ }
+ }
+ mAllBanners.clear();
+ }
+ // 置空当前广告引用
+ mBannerAd = null;
+ // 移除广告容器中的视图
+ if (mContainer != null) {
+ mContainer.removeAllViews();
+ }
+ }
+
+ /**
+ * 显示广告(核心修复:传递安全的Context + 生命周期校验)
+ */
+ private void showAd(final Activity activity) {
+ // 没有设置米盟广告支持就退出
+ if (ADsControlView.getAdsModeFromStatic(this.mContext) != ADsMode.MIMO_SDK) {
+ return;
+ }
+
+ LogUtils.d(TAG, "showAd()");
+ // 1. 生命周期校验:避免Activity已销毁时操作UI
+ if (activity == null || activity.isFinishing() || activity.isDestroyed()) {
+ LogUtils.e(TAG, "showAd: Activity is finishing or destroyed");
+ return;
+ }
+ // 2. 非空校验:广告实例和容器
+ if (mBannerAd == null || mContainer == null) {
+ LogUtils.e(TAG, "showAd: BannerAd or Container is null");
+ return;
+ }
+ // 3. 创建广告容器(使用ApplicationContext避免内存泄漏)
+ final FrameLayout container = new FrameLayout(activity.getApplicationContext());
+ container.setPadding(0, 0, 0, MimoUtils.dpToPx(activity, 10));
+ mContainer.addView(container, new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT
+ ));
+
+// if (mIsBiddingWin) {
+// mBannerAd.setPrice(getPrice());
+// }
+ // 4. 显示广告:传递ApplicationContext,避免Activity Context失效
+ mBannerAd.showAd(activity, container, new BannerAd.BannerInteractionListener() {
+ @Override
+ public void onAdClick() {
+ LogUtils.d(TAG, "onAdClick");
+ }
+
+ @Override
+ public void onAdShow() {
+ LogUtils.d(TAG, "onAdShow");
+ }
+
+ @Override
+ public void onAdDismiss() {
+ LogUtils.d(TAG, "onAdDismiss");
+ // 修复:移除容器时校验Activity状态
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed() && mContainer != null) {
+ mContainer.removeView(container);
+ }
+ }
+
+ @Override
+ public void onRenderSuccess() {
+ LogUtils.d(TAG, "onRenderSuccess");
+ }
+
+ @Override
+ public void onRenderFail(int code, String msg) {
+ LogUtils.e(TAG, "onRenderFail errorCode " + code + " errorMsg " + msg);
+ // 修复:渲染失败时移除容器
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed() && mContainer != null) {
+ mContainer.removeView(container);
+ }
+ }
+ });
+ }
+
+ /**
+ * 请求广告(核心修复:Context安全校验 + 异常捕获 + 资源管理)
+ */
+ private void fetchAd(final Activity activity) {
+ // 没有设置米盟广告支持就退出
+ if (ADsControlView.getAdsModeFromStatic(this.mContext) != ADsMode.MIMO_SDK) {
+ return;
+ }
+
+ LogUtils.d(TAG, "fetchAd()");
+ // 1. 双重校验:Activity未销毁 + Context非空
+ if (activity == null || activity.isFinishing() || activity.isDestroyed() || activity.getApplicationContext() == null) {
+ LogUtils.e(TAG, "fetchAd: Invalid Context or Activity state");
+ return;
+ }
+ // 2. 释放之前的广告资源,避免内存泄漏
+ if (mBannerAd != null) {
+ mBannerAd.destroy();
+ }
+ // 3. 初始化广告(使用ApplicationContext,避免Activity Context失效)
+ try {
+ mBannerAd = new BannerAd();
+ mAllBanners.add(mBannerAd);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "fetchAd: Init BannerAd failed", e);
+ return;
+ }
+ // 4. 设置下载监听
+ mBannerAd.setDownLoadListener(new BannerAd.BannerDownloadListener() {
+ @Override
+ public void onDownloadStarted() {
+ LogUtils.d(TAG, "onDownloadStarted");
+ }
+
+ @Override
+ public void onDownloadPaused() {
+ LogUtils.d(TAG, "onDownloadPaused");
+ }
+
+ @Override
+ public void onDownloadFailed(int errorCode) {
+ String msg = "onDownloadFailed, errorCode = " + errorCode;
+ LogUtils.d(TAG, msg);
+ //ToastUtils.show(msg);
+ }
+
+ @Override
+ public void onDownloadFinished() {
+ LogUtils.d(TAG, "onDownloadFinished");
+ }
+
+ @Override
+ public void onDownloadProgressUpdated(int progress) {
+ LogUtils.d(TAG, "onDownloadProgressUpdated " + progress + "%");
+ }
+
+ @Override
+ public void onInstallFailed(int errorCode) {
+ LogUtils.d(TAG, "onInstallFailed, errorCode = " + errorCode);
+ }
+
+ @Override
+ public void onInstallStart() {
+ LogUtils.d(TAG, "onInstallStart");
+ }
+
+ @Override
+ public void onInstallSuccess() {
+ LogUtils.d(TAG, "onInstallSuccess");
+ }
+
+ @Override
+ public void onDownloadCancel() {
+ LogUtils.d(TAG, "onDownloadCancel");
+ }
+ });
+
+ // 5. 构建广告参数并请求
+ String currentAD_ID = getAD_ID();
+ LogUtils.d(TAG, String.format("currentAD_ID = %s", currentAD_ID));
+ ADParams params = new ADParams.Builder().setUpId(currentAD_ID).build();
+ mBannerAd.loadAd(params, new BannerAd.BannerLoadListener() {
+ @Override
+ public void onBannerAdLoadSuccess() {
+ LogUtils.d(TAG, "onBannerAdLoadSuccess()");
+ // 修复:广告加载成功后校验Activity状态
+ if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) {
+ showAd(activity);
+ //ToastUtils.show("showAd()");
+ }
+ }
+
+ @Override
+ public void onAdLoadFailed(int errorCode, String errorMsg) {
+ String msg = "onAdLoadFailed: errorCode = " + errorCode + ", errorMsg = " + errorMsg;
+ LogUtils.d(TAG, msg);
+ removeAllBanners();
+ }
+ });
+ }
+
+ void removeAllBanners() {
+ // 没有设置米盟广告支持就退出
+ if (ADsControlView.getAdsModeFromStatic(this.mContext) != ADsMode.MIMO_SDK) {
+ return;
+ }
+
+ // 修复:加载失败时移除当前广告实例
+ if (mAllBanners.contains(mBannerAd)) {
+ mAllBanners.remove(mBannerAd);
+ }
+ mBannerAd.destroy();
+ mBannerAd = null;
+ }
+
+ /**
+ * 根据当前秒数获取广告ID(原逻辑保留)
+ */
+ private String getAD_ID() {
+ long currentSecond = System.currentTimeMillis() / 1000;
+ return (currentSecond % 2 == 0) ? BANNER_POS_ID :
+ (GlobalApplication.isDebugging() ? BANNER_POS_ID_WINBOLL_BETA : BANNER_POS_ID_WINBOLL);
+ }
+
+ /**
+ * 获取广告价格(原逻辑保留,添加空指针校验)
+ */
+// private long getPrice() {
+// if (mBannerAd == null) {
+// return 0;
+// }
+// Map map = mBannerAd.getMediaExtraInfo();
+// if (map == null || map.isEmpty() || !map.containsKey("price")) {
+// LogUtils.w(TAG, "getPrice: media extra info is null or no price key");
+// return 0;
+// }
+// Object priceObj = map.get("price");
+// if (priceObj instanceof Long) {
+// return (Long) priceObj;
+// } else if (priceObj instanceof Integer) {
+// return ((Integer) priceObj).longValue();
+// } else {
+// LogUtils.e(TAG, "getPrice: price type is invalid");
+// return 0;
+// }
+// }
+
+ /**
+ * 显示隐私协议弹窗(原逻辑保留,优化Context使用)
+ */
+// private void showPrivacy() {
+// // 校验Activity状态,避免弹窗泄露
+// if (getActivity() == null || getActivity().isFinishing() || getActivity().isDestroyed()) {
+// return;
+// }
+// ADsMode adsMode = ADsControlView.getAdsModeFromStatic(this.mContext);
+// if (adsMode == ADsMode.STANDALONE) {
+// ADsControlView.updateAdsModeByStatic(this.mContext, ADsMode.STANDALONE);
+// LogUtils.i(TAG, "单机模式,广告已处于不可用状态...");
+// Toast.makeText(getActivity().getApplicationContext(), "单机模式,广告已处于不可用状态...", Toast.LENGTH_SHORT).show();
+// return;
+// } else if (adsMode == ADsMode.MIMO_SDK) {
+// ADsControlView.updateAdsModeByStatic(this.mContext, ADsMode.MIMO_SDK);
+// LogUtils.i(TAG, "米盟广告SDK支持模式,现在初始化SDK...");
+// initMimoSdk();
+// return;
+// }
+// else {
+// LogUtils.i(TAG, "开始弹出隐私协议...");
+// AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+// builder.setTitle("用户须知");
+// builder.setMessage("小米广告SDK隐私政策: https://dev.mi.com/distribute/doc/details?pId=1688, 请复制到浏览器查看");
+// builder.setIcon(R.drawable.ic_launcher);
+// builder.setCancelable(false); // 点击对话框以外的区域不消失
+// builder.setPositiveButton("同意", new DialogInterface.OnClickListener() {
+// @Override
+// public void onClick(DialogInterface dialog, int which) {
+// getSharedPreferences().edit()
+// .putString(PRIVACY_VALUE, String.valueOf(1))
+// .apply();
+// initMimoSdk();
+// dialog.dismiss();
+// }
+// });
+// builder.setNegativeButton("拒绝", new DialogInterface.OnClickListener() {
+// @Override
+// public void onClick(DialogInterface dialog, int which) {
+// getSharedPreferences().edit()
+// .putString(PRIVACY_VALUE, String.valueOf(0))
+// .apply();
+// dialog.dismiss();
+// }
+// });
+// AlertDialog dialog = builder.create();
+//
+// // 配置弹窗位置(底部全屏)
+// Window window = dialog.getWindow();
+// if (window != null) {
+// window.setGravity(Gravity.BOTTOM);
+// WindowManager m = getActivity().getWindowManager();
+// Display d = m.getDefaultDisplay();
+// WindowManager.LayoutParams p = window.getAttributes();
+// p.width = d.getWidth();
+// window.setAttributes(p);
+// }
+// dialog.show();
+// }
+// }
+
+ /**
+ * 初始化米盟SDK(核心修复:传递ApplicationContext + 异常捕获)
+ */
+ private void initMimoSdk(Context context) {
+ // 1. 安全获取ApplicationContext,避免Activity Context失效
+ Context appContext = context.getApplicationContext();
+ if (appContext == null) {
+ LogUtils.e(TAG, "initMimoSdk: ApplicationContext is null");
+ return;
+ }
+ // 2. 初始化SDK,捕获异常避免崩溃
+ try {
+ MimoSdk.init(appContext, new MimoCustomController() {
+ @Override
+ public boolean isCanUseLocation() {
+ return true;
+ }
+
+ @Override
+ public MimoLocation getMimoLocation() {
+ return null;
+ }
+
+ @Override
+ public boolean isCanUseWifiState() {
+ return true;
+ }
+
+ @Override
+ public boolean alist() {
+ return true;
+ }
+ }, new MimoSdk.InitCallback() {
+ @Override
+ public void success() {
+ LogUtils.d(TAG, "MimoSdk init success");
+ }
+
+ @Override
+ public void fail(int code, String msg) {
+ LogUtils.e(TAG, "MimoSdk init fail, code=" + code + ",msg=" + msg);
+ }
+ });
+ MimoSdk.setDebugOn(true);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "initMimoSdk: init failed", e);
+ }
+ }
+
+
+ /**
+ * 获取SharedPreferences实例(原逻辑保留,添加空指针校验)
+ */
+// SharedPreferences getSharedPreferences() {
+//// if (mSharedPreferences == null) {
+//// // 修复:使用ApplicationContext获取SharedPreferences,避免Activity Context泄露
+//// Context appContext = getActivity().getApplicationContext();
+//// if (appContext != null) {
+//// mSharedPreferences = appContext.getSharedPreferences(PRIVACY_FILE, Context.MODE_PRIVATE);
+//// } else {
+//// LogUtils.e(TAG, "getSharedPreferences: ApplicationContext is null");
+//// // 降级方案:若ApplicationContext为空,使用Activity Context(仅作兼容)
+//// mSharedPreferences = getActivity().getSharedPreferences(PRIVACY_FILE, Context.MODE_PRIVATE);
+//// }
+//// }
+// return mSharedPreferences;
+// }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/ADsControlView.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/ADsControlView.java
new file mode 100644
index 0000000..c24efca
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/ADsControlView.java
@@ -0,0 +1,633 @@
+package cc.winboll.studio.libaes.views;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.text.Html;
+import android.util.AttributeSet;
+import android.view.Display;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.PopupMenu;
+import android.widget.PopupWindow;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.appcompat.app.AlertDialog;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.enums.ADsMode;
+import cc.winboll.studio.libaes.enums.PrivacyAgreeStatus;
+import cc.winboll.studio.libaes.utils.WebUtils;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.ToastUtils;
+import com.miui.zeus.mimo.sdk.MimoCustomController;
+import com.miui.zeus.mimo.sdk.MimoLocation;
+import com.miui.zeus.mimo.sdk.MimoSdk;
+import java.lang.reflect.Field;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/26 17:51
+ * @LastEditTime 2026/01/08 11:00:00 HKT
+ * @Describe 广告模式控制控件(Java 7 兼容,云宝物语模式)
+ * 核心修改:将PopupMenu锚点绑定到view_popmenu_anchor_point控件,菜单精准显示在锚点位置
+ */
+public class ADsControlView extends LinearLayout {
+ public static final String TAG = "ADsControlView";
+
+ // SP存储配置
+ private static final String SP_NAME = "ads_control_config";
+ private static final String KEY_SELECTED_MODE = "selected_ads_mode";
+ ADsMode mADsMode;
+ private static final String PRIVACY_VALUE = "privacy_value";
+ PrivacyAgreeStatus mPrivacyAgreeStatus;
+
+ // Handler消息标识
+ private static final int MSG_UPDATE_MODE = 1001;
+
+ // 控件引用
+ private RadioGroup rgADsMode;
+ private RadioButton rbStandalone;
+ private RadioButton rbMimoSDK;
+ private RadioButton rbStoreQrcode;
+ private RelativeLayout rlWinbollStore;
+ private ImageView ivWinbollStoreQrcode;
+ // 新增:锚点控件引用
+ private TextView viewPopmenuAnchorPoint;
+
+ // 外部监听、SP实例、Handler实例
+ private OnAdsModeSelectedListener listener;
+ private SharedPreferences sharedPreferences;
+ private InternalHandler mHandler;
+ private Context mContext;
+
+ // 静态列表:存储所有已创建的控件实例
+ private static final java.util.List sControlViews = new java.util.ArrayList();
+
+ // 常量定义
+ private static final String WECHAT_STORE_URL = "https://store.weixin.qq.com/shop/b/XhrPkZgoeHo4zug";
+ private static final int MENU_ITEM_OPEN_STORE = 1001;
+
+ // 构造方法(Java 7 兼容)
+ public ADsControlView(Context context) {
+ super(context);
+ initView(context);
+ }
+
+ public ADsControlView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initView(context);
+ }
+
+ @SuppressWarnings("deprecation")
+ public ADsControlView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initView(context);
+ }
+
+ public void setPrivacyAgreeStatus(PrivacyAgreeStatus privacyAgreeStatus) {
+ this.mPrivacyAgreeStatus = privacyAgreeStatus;
+ sharedPreferences.edit().putString(PRIVACY_VALUE, this.mPrivacyAgreeStatus.name()).apply();
+ }
+
+ public PrivacyAgreeStatus getPrivacyAgreeStatus() {
+ String privacyAgreeStatusStr = sharedPreferences.getString(PRIVACY_VALUE, PrivacyAgreeStatus.UN_SIGNED.name());
+ PrivacyAgreeStatus privacyAgreeStatus = PrivacyAgreeStatus.fromString(privacyAgreeStatusStr);
+ return privacyAgreeStatus;
+ }
+
+ public void setADsMode(ADsMode mADsMode) {
+ this.mADsMode = mADsMode;
+ sharedPreferences.edit().putString(KEY_SELECTED_MODE, this.mADsMode.name()).apply();
+ updateStoreQrcodeLayoutVisibility(mADsMode);
+ }
+
+ public ADsMode getADsMode() {
+ String savedModeStr = sharedPreferences.getString(KEY_SELECTED_MODE, ADsMode.STANDALONE.name());
+ mADsMode = ADsMode.fromValue(savedModeStr);
+ return mADsMode;
+ }
+
+ /**
+ * 初始化视图、SP、Handler
+ */
+ private void initView(final Context context) {
+ this.mContext = context;
+
+ // 加载布局
+ LayoutInflater.from(context).inflate(R.layout.view_adscontrol, this, true);
+
+ // 初始化SP
+ sharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
+
+ // 绑定控件
+ rgADsMode = (RadioGroup) findViewById(R.id.rg_ads_mode);
+ rbStandalone = (RadioButton) findViewById(R.id.rb_standalone);
+ rbMimoSDK = (RadioButton) findViewById(R.id.rb_mimo_sdk);
+ rbStoreQrcode = (RadioButton) findViewById(R.id.rb_store_qrcode);
+ rlWinbollStore = (RelativeLayout) findViewById(R.id.rl_winboll_store);
+ ivWinbollStoreQrcode = (ImageView) findViewById(R.id.iv_winboll_store);
+ // 绑定锚点控件
+ viewPopmenuAnchorPoint = (TextView) findViewById(R.id.view_popmenu_anchor_point);
+
+ // 初始化Handler
+ mHandler = new InternalHandler(Looper.getMainLooper());
+
+ // 核心修改:初始化图片的点击和长按事件(锚点改为view_popmenu_anchor_point)
+ initImageViewClickAndLongClick();
+
+ // 注册控件实例
+ registerControlView(this);
+
+ // 从SP读取初始模式
+ setSelectedMode(getADsMode());
+
+ // 单选组选择事件监听
+ rgADsMode.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(RadioGroup group, int checkedId) {
+ if (checkedId == R.id.rb_standalone) {
+ setADsMode(ADsMode.STANDALONE);
+ if (listener != null) listener.onModeSelected(ADsMode.STANDALONE);
+ } else if (checkedId == R.id.rb_mimo_sdk) {
+ handlePrivacyLogic((Activity) context, PrivacyAgreeStatus.UN_SIGNED, new OnPrivacyChangeListener() {
+ @Override
+ public void onAgreePrivacy() {
+ setADsMode(ADsMode.MIMO_SDK);
+ if (listener != null) listener.onModeSelected(ADsMode.MIMO_SDK);
+ }
+
+ @Override
+ public void onDisagreePrivacy() {
+ setADsMode(ADsMode.STANDALONE);
+ setSelectedMode(ADsMode.STANDALONE);
+ if (listener != null) listener.onModeSelected(ADsMode.STANDALONE);
+ }
+ });
+ } else if (checkedId == R.id.rb_store_qrcode) {
+ setADsMode(ADsMode.STORE_QRCODE);
+ if (listener != null) listener.onModeSelected(ADsMode.STORE_QRCODE);
+ }
+ }
+ });
+ }
+
+ /**
+ * 初始化图片的点击和长按事件
+ * 核心:将PopupMenu锚点绑定到view_popmenu_anchor_point控件
+ */
+ private void initImageViewClickAndLongClick() {
+ if (ivWinbollStoreQrcode == null || viewPopmenuAnchorPoint == null) {
+ LogUtils.e(TAG, "initImageViewClickAndLongClick: 控件引用为空");
+ return;
+ }
+
+ // 1. 点击事件:简化为提示信息
+ ivWinbollStoreQrcode.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ToastUtils.show("长按图片可打开微信小店");
+ LogUtils.d(TAG, "图片点击:提示用户长按打开微信小店");
+ }
+ });
+
+ // 2. 长按事件:锚点改为view_popmenu_anchor_point
+ ivWinbollStoreQrcode.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ // 计算锚点控件的屏幕坐标(用于菜单位置微调)
+ int[] anchorLocation = new int[2];
+ viewPopmenuAnchorPoint.getLocationOnScreen(anchorLocation);
+ final int anchorX = anchorLocation[0];
+ final int anchorY = anchorLocation[1];
+
+ // 创建PopupMenu,锚点绑定到view_popmenu_anchor_point
+ PopupMenu popupMenu = new PopupMenu(mContext, viewPopmenuAnchorPoint);
+ // 设置菜单重力:相对锚点居中显示
+ popupMenu.setGravity(Gravity.CENTER);
+
+ Menu menu = popupMenu.getMenu();
+ menu.add(Menu.NONE, MENU_ITEM_OPEN_STORE, Menu.NONE, "打开微信小店");
+
+ // 设置菜单点击事件
+ popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ if (item.getItemId() == MENU_ITEM_OPEN_STORE) {
+ WebUtils.openUrlInBrowser(mContext, WECHAT_STORE_URL);
+ return true;
+ }
+ return false;
+ }
+ });
+
+ try {
+ // 反射获取PopupWindow,微调菜单位置(可选)
+ Field popupField = PopupMenu.class.getDeclaredField("mPopup");
+ popupField.setAccessible(true);
+ Object popupObject = popupField.get(popupMenu);
+ if (popupObject instanceof PopupWindow) {
+ final PopupWindow popupWindow = (PopupWindow) popupObject;
+ popupWindow.setAnimationStyle(0); // 关闭默认动画
+
+ // 延迟微调菜单位置(确保布局测量完成)
+ new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ int menuX = anchorX + viewPopmenuAnchorPoint.getWidth() / 2 - popupWindow.getWidth() / 2;
+ int menuY = anchorY + viewPopmenuAnchorPoint.getHeight() / 2 - popupWindow.getHeight() / 2;
+ if (!popupWindow.isShowing()) {
+ popupWindow.showAtLocation(viewPopmenuAnchorPoint, Gravity.NO_GRAVITY, menuX, menuY);
+ }
+ }
+ }, 30);
+ }
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ LogUtils.e(TAG, "反射获取PopupWindow失败", e);
+ }
+
+ // 显示菜单
+ popupMenu.show();
+ LogUtils.d(TAG, "长按图片,菜单锚点为view_popmenu_anchor_point");
+ return true;
+ }
+ });
+
+ // 设置控件可交互标识
+ ivWinbollStoreQrcode.setClickable(true);
+ ivWinbollStoreQrcode.setFocusable(true);
+ ivWinbollStoreQrcode.setLongClickable(true);
+ viewPopmenuAnchorPoint.setClickable(false); // 锚点控件不可点击
+ viewPopmenuAnchorPoint.setLongClickable(false);
+ }
+
+ /**
+ * 从ImageView中提取Bitmap(保留方法,无实际调用)
+ */
+ private Bitmap getBitmapFromImageView(ImageView imageView) {
+ Drawable drawable = imageView.getDrawable();
+ if (drawable instanceof BitmapDrawable) {
+ return ((BitmapDrawable) drawable).getBitmap();
+ }
+ return null;
+ }
+
+ /**
+ * 压缩Bitmap(备用方法,无实际调用)
+ */
+ private Bitmap compressBitmapBySize(Bitmap src, int maxWidth, int maxHeight) {
+ if (src == null) return null;
+ int width = src.getWidth();
+ int height = src.getHeight();
+ float scale = Math.min((float) maxWidth / width, (float) maxHeight / height);
+ int newWidth = (int) (width * scale);
+ int newHeight = (int) (height * scale);
+ return Bitmap.createScaledBitmap(src, newWidth, newHeight, true);
+ }
+
+ /**
+ * 计算Bitmap采样率(备用方法,无实际调用)
+ */
+ private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
+ final int height = options.outHeight;
+ final int width = options.outWidth;
+ int inSampleSize = 1;
+ if (height > reqHeight || width > reqWidth) {
+ final int halfHeight = height / 2;
+ final int halfWidth = width / 2;
+ while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
+ inSampleSize *= 2;
+ }
+ }
+ return inSampleSize;
+ }
+
+ /**
+ * 从ImageView反射获取资源ID(备用方法,无实际调用)
+ */
+ private int getResIdFromImageView(ImageView imageView) {
+ try {
+ Field field = ImageView.class.getDeclaredField("mSrcResource");
+ field.setAccessible(true);
+ return field.getInt(imageView);
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ LogUtils.e(TAG, "getResIdFromImageView: 反射失败", e);
+ return 0;
+ }
+ }
+
+ /**
+ * 更新二维码布局显示状态
+ */
+ private void updateStoreQrcodeLayoutVisibility(ADsMode mode) {
+ if (rlWinbollStore == null) return;
+ rlWinbollStore.setVisibility(mode == ADsMode.STORE_QRCODE ? View.VISIBLE : View.GONE);
+ }
+
+ /**
+ * 清理SP中的隐私协议状态
+ */
+ public static void cleanPrivacyStatus(Context context) {
+ if (context == null) {
+ LogUtils.e(TAG, "cleanPrivacyStatus: Context is null");
+ return;
+ }
+ SharedPreferences sp = getPrivacySharedPreferences(context);
+ sp.edit().remove(PRIVACY_VALUE).apply();
+ LogUtils.i(TAG, "隐私协议状态清理成功");
+ ToastUtils.show("隐私协议状态已清理");
+ }
+
+ /**
+ * 获取隐私协议SP实例
+ */
+ private static SharedPreferences getPrivacySharedPreferences(Context context) {
+ Context appContext = context.getApplicationContext();
+ if (appContext != null) {
+ return appContext.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
+ }
+ return context.getSharedPreferences(PRIVACY_VALUE, Context.MODE_PRIVATE);
+ }
+
+ /**
+ * 处理隐私协议逻辑
+ */
+ private static void handlePrivacyLogic(final Activity activity, PrivacyAgreeStatus privacyAgreeStatus, final OnPrivacyChangeListener onPrivacyChangeListener) {
+ if (privacyAgreeStatus == PrivacyAgreeStatus.REJECTED) {
+ Toast.makeText(activity.getApplicationContext(), "已拒绝隐私协议,广告已处于不可用状态", Toast.LENGTH_SHORT).show();
+ return;
+ } else if (privacyAgreeStatus == PrivacyAgreeStatus.AGREED) {
+ initMimoSdkStatic(activity.getApplicationContext());
+ return;
+ } else {
+ AlertDialog dialog = createPrivacyDialog(activity, onPrivacyChangeListener);
+ Window window = dialog.getWindow();
+ if (window != null) {
+ window.setGravity(Gravity.BOTTOM);
+ WindowManager m = activity.getWindowManager();
+ Display d = m.getDefaultDisplay();
+ WindowManager.LayoutParams p = window.getAttributes();
+ p.width = d.getWidth();
+ window.setAttributes(p);
+ }
+ dialog.show();
+ }
+ }
+
+ /**
+ * 初始化米盟SDK
+ */
+ private static void initMimoSdkStatic(Context appContext) {
+ if (appContext == null) return;
+ try {
+ MimoSdk.init(appContext, new MimoCustomController() {
+ @Override
+ public boolean isCanUseLocation() {
+ return true;
+ }
+
+ @Override
+ public MimoLocation getMimoLocation() {
+ return null;
+ }
+
+ @Override
+ public boolean isCanUseWifiState() {
+ return true;
+ }
+
+ @Override
+ public boolean alist() {
+ return true;
+ }
+ }, new MimoSdk.InitCallback() {
+ @Override
+ public void success() {
+ LogUtils.d(TAG, "米盟SDK初始化成功");
+ }
+
+ @Override
+ public void fail(int code, String msg) {
+ LogUtils.e(TAG, "米盟SDK初始化失败:" + code + ", " + msg);
+ }
+ });
+ MimoSdk.setDebugOn(true);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "米盟SDK初始化异常", e);
+ }
+ }
+
+ /**
+ * 静态方法:更新SP中的模式
+ */
+ public static void updateAdsModeByStatic(Context context, ADsMode mode) {
+ if (context == null || mode == null) return;
+ SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
+ sp.edit().putString(KEY_SELECTED_MODE, mode.name()).apply();
+ InternalHandler.sendUpdateModeMessage(mode);
+ }
+
+ /**
+ * 静态方法:读取SP中的模式
+ */
+ public static ADsMode getAdsModeFromStatic(Context context) {
+ if (context == null) return ADsMode.STANDALONE;
+ SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
+ String savedModeStr = sp.getString(KEY_SELECTED_MODE, ADsMode.STANDALONE.name());
+ return ADsMode.fromValue(savedModeStr);
+ }
+
+ /**
+ * 注册控件实例
+ */
+ private static void registerControlView(ADsControlView view) {
+ synchronized (sControlViews) {
+ if (!sControlViews.contains(view)) {
+ sControlViews.add(view);
+ }
+ }
+ }
+
+ /**
+ * 移除控件实例
+ */
+ private static void unregisterControlView(ADsControlView view) {
+ synchronized (sControlViews) {
+ sControlViews.remove(view);
+ }
+ }
+
+ /**
+ * 设置选中模式
+ */
+ private void setSelectedMode(final ADsMode mode) {
+ final ADsMode mode2 = (mode == null) ? ADsMode.STANDALONE : mode;
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ if (mode2 == ADsMode.STANDALONE) {
+ rbStandalone.setChecked(true);
+ } else if (mode2 == ADsMode.MIMO_SDK) {
+ rbMimoSDK.setChecked(true);
+ } else if (mode2 == ADsMode.STORE_QRCODE) {
+ rbStoreQrcode.setChecked(true);
+ }
+ updateStoreQrcodeLayoutVisibility(mode2);
+ } else {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ setSelectedMode(mode2);
+ }
+ });
+ }
+ }
+
+ /**
+ * 获取选中模式
+ */
+ public ADsMode getSelectedMode() {
+ int checkedId = rgADsMode.getCheckedRadioButtonId();
+ if (checkedId == R.id.rb_mimo_sdk) {
+ return ADsMode.MIMO_SDK;
+ } else if (checkedId == R.id.rb_store_qrcode) {
+ return ADsMode.STORE_QRCODE;
+ } else {
+ return ADsMode.STANDALONE;
+ }
+ }
+
+ /**
+ * 设置外部监听
+ */
+ public void setOnAdsModeSelectedListener(OnAdsModeSelectedListener listener) {
+ this.listener = listener;
+ }
+
+ /**
+ * 内部Handler类
+ */
+ private static class InternalHandler extends Handler {
+ static volatile InternalHandler _InternalHandler;
+
+ public InternalHandler(Looper looper) {
+ super(looper);
+ _InternalHandler = this;
+ }
+
+ public static void sendUpdateModeMessage(ADsMode mode) {
+ if (mode == null || _InternalHandler == null) return;
+ Message msg = _InternalHandler.obtainMessage();
+ msg.what = MSG_UPDATE_MODE;
+ msg.obj = mode;
+ _InternalHandler.sendMessage(msg);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ super.handleMessage(msg);
+ if (msg.what == MSG_UPDATE_MODE) {
+ ADsMode mode = (ADsMode) msg.obj;
+ if (mode == null) return;
+ synchronized (sControlViews) {
+ for (ADsControlView view : sControlViews) {
+ if (view != null && view.isShown() && view.isAttachedToWindow()) {
+ view.setSelectedMode(mode);
+ view.updateStoreQrcodeLayoutVisibility(mode);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * 生命周期:控件销毁
+ */
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ if (mHandler != null) {
+ mHandler.removeCallbacksAndMessages(null);
+ }
+ unregisterControlView(this);
+ }
+
+ /**
+ * 外部监听接口
+ */
+ public interface OnAdsModeSelectedListener {
+ void onModeSelected(ADsMode selectedMode);
+ }
+
+ /**
+ * 隐私协议监听接口
+ */
+ public interface OnPrivacyChangeListener {
+ void onAgreePrivacy();
+ void onDisagreePrivacy();
+ }
+
+ /**
+ * 创建隐私协议对话框
+ */
+ private static AlertDialog createPrivacyDialog(final Activity activity, final OnPrivacyChangeListener onPrivacyChangeListener) {
+ View dialogView = LayoutInflater.from(activity).inflate(R.layout.dialog_privacy_agreement, null);
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+ builder.setView(dialogView).setCancelable(false);
+ final AlertDialog dialog = builder.create();
+
+ final TextView tvPrivacyUrl = (TextView) dialogView.findViewById(R.id.tv_privacy_url);
+ Button btnAgree = (Button) dialogView.findViewById(R.id.btn_agree);
+ Button btnDisagree = (Button) dialogView.findViewById(R.id.btn_disagree);
+
+ tvPrivacyUrl.setText(Html.fromHtml("" + tvPrivacyUrl.getText().toString() + ""));
+ tvPrivacyUrl.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String url = tvPrivacyUrl.getText().toString().trim();
+ ToastUtils.show("隐私协议链接:" + url);
+ }
+ });
+ tvPrivacyUrl.setClickable(true);
+ tvPrivacyUrl.setFocusable(true);
+
+ btnAgree.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (onPrivacyChangeListener != null) {
+ onPrivacyChangeListener.onAgreePrivacy();
+ }
+ dialog.dismiss();
+ }
+ });
+
+ btnDisagree.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (onPrivacyChangeListener != null) {
+ onPrivacyChangeListener.onDisagreePrivacy();
+ }
+ dialog.dismiss();
+ }
+ });
+
+ return dialog;
+ }
+}
+
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/AOHPCTCSeekBar.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/AOHPCTCSeekBar.java
new file mode 100644
index 0000000..432fbde
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/AOHPCTCSeekBar.java
@@ -0,0 +1,108 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:46:30
+ * @Describe AOneHundredPercantClickToCommitSeekBar
+ */
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.SeekBar;
+import cc.winboll.studio.libappbase.LogUtils;
+
+public class AOHPCTCSeekBar extends SeekBar {
+
+ public static final String TAG = "AOHPCTCSeekBar";
+
+ volatile int thumbWidth = 1;
+ volatile int progressBarWidth = 1;
+ // 设置按钮模糊右边边缘像素
+ volatile int blurRightDP = 1;
+ // 是否从起点拉动的标志
+ volatile boolean isStartSeek = false;
+
+ // 外部接口对象,确定事件提交会调用该对象的方法
+ OnOHPCListener mOnOHPCListener;
+
+
+ public void setBlurRightDP(int blurRight) {
+ this.blurRightDP = blurRight;
+ }
+
+ public void setOnOHPCListener(OnOHPCListener listener) {
+ mOnOHPCListener = listener;
+ }
+
+ public interface OnOHPCListener {
+ abstract void onOHPCommit();
+ }
+
+ public AOHPCTCSeekBar(Context context) {
+ super(context);
+ initView(context);
+ }
+
+ public AOHPCTCSeekBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initView(context);
+ }
+
+ public AOHPCTCSeekBar(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initView(context);
+ }
+
+ void initView(Context context) {
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ if (thumbWidth + blurRightDP > event.getX() && event.getX() > 0) {
+ getParent().requestDisallowInterceptTouchEvent(true);
+ isStartSeek = true;
+ }
+ } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
+ if (isStartSeek) {
+ super.dispatchTouchEvent(event);
+ }
+ } else if (event.getAction() == MotionEvent.ACTION_UP
+ || event.getAction() == MotionEvent.ACTION_CANCEL) {
+ getParent().requestDisallowInterceptTouchEvent(false);
+ if (getProgress() == progressBarWidth) {
+ mOnOHPCListener.onOHPCommit();
+ }
+ // 重置控件状态
+ setProgress(0);
+ isStartSeek = false;
+ }
+ return true;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ int width = MeasureSpec.getSize(widthMeasureSpec);
+ //int height = MeasureSpec.getSize(heightMeasureSpec);
+ //LogUtils.d(TAG, String.format("width %d height %d", width, height));
+
+ // 获取SeekBar的图标宽度
+ Drawable thumbDrawable = getThumb();
+ if (thumbDrawable != null) {
+ // 获取图标宽度
+ thumbWidth = thumbDrawable.getIntrinsicWidth();
+ }
+
+ // 获取进度条宽度
+ progressBarWidth = width;
+
+ //LogUtils.d(TAG, String.format("thumbWidth %d progressBarWidth %d", thumbWidth, progressBarWidth));
+
+ // 设置图标位置
+ setThumbOffset(0);
+ // 设置进度条刻度
+ setMax(progressBarWidth);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/AOHPCTSCard.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/AOHPCTSCard.java
new file mode 100644
index 0000000..a7120ea
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/AOHPCTSCard.java
@@ -0,0 +1,43 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:49:23
+ * @Describe AOneHundredPercantClickToStartCard
+ */
+import android.content.Context;
+import android.util.AttributeSet;
+
+public class AOHPCTSCard extends ACard {
+
+ public static final String TAG = "AOHPCTSCard";
+
+ public AOHPCTSCard(Context context) {
+ super(context);
+ }
+
+ public AOHPCTSCard(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ //setPadding(0 + 0 + 2 + 1, 0 + 0 + 2 + 1, 0 + 1 + 3 + 1, 0 + 2 + 3 + 1);
+
+ // 获得TypedArray
+ //TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AToolbar);
+ // 获得attrs.xml里面的属性值,格式为:名称_属性名,后面是默认值
+ //int colorBackgroud = a.getColor(R.styleable.ACard_backgroudColor, context.getColor(R.color.colorACardBackgroung));
+ //int centerColor = a.getColor(R.styleable.AToolbar_centerColor, context.getColor(R.color.colorAToolbarCenterColor));
+ //int endColor = a.getColor(R.styleable.AToolbar_endColor, context.getColor(R.color.colorAToolbarEndColor));
+ //float tSize = a.getDimension(R.styleable.CustomView_tSize, 35);
+ //p.setColor(tColor);
+ //p.setTextSize(tSize);
+ //Drawable drawable = context.getDrawable(R.drawable.frame_atoolbar);
+
+ //setBackground(context.getDrawable(R.drawable.acard_frame_main));
+
+ // 返回一个绑定资源结束的信号给资源
+ //a.recycle();
+ }
+
+ public AOHPCTSCard(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/ASupportToolbar.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/ASupportToolbar.java
new file mode 100644
index 0000000..0c55384
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/ASupportToolbar.java
@@ -0,0 +1,93 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:54:40
+ * @Describe ASupportToolbar
+ */
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.LayerDrawable;
+import android.util.AttributeSet;
+import androidx.appcompat.widget.Toolbar;
+import cc.winboll.studio.libaes.R;
+import android.graphics.drawable.Drawable;
+import androidx.core.content.ContextCompat;
+import android.graphics.PorterDuff;
+
+public class ASupportToolbar extends Toolbar {
+
+ public static final String TAG = "ASupportToolbar";
+
+ int mTitleTextColor;
+ int mStartColor;
+ int mCenterColor;
+ int mEndColor;
+ LayerDrawable ld;
+ GradientDrawable[] array = new GradientDrawable[3];
+ //private GradientDrawable gradientDrawable;
+
+ public ASupportToolbar(Context context) {
+ super(context);
+ }
+
+ public ASupportToolbar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ASupportToolbar, R.attr.aSupportToolbar, 0);
+ mTitleTextColor = a.getColor(R.styleable.ASupportToolbar_attrASupportToolbarTitleTextColor, Color.GREEN);
+ mStartColor = a.getColor(R.styleable.ASupportToolbar_attrASupportToolbarStartColor, Color.BLUE);
+ mCenterColor = a.getColor(R.styleable.ASupportToolbar_attrASupportToolbarCenterColor, Color.RED);
+ mEndColor = a.getColor(R.styleable.ASupportToolbar_attrASupportToolbarEndColor, Color.YELLOW);
+ // 返回一个绑定资源结束的信号给资源
+ a.recycle();
+ notifyColorChange();
+ }
+
+ void notifyColorChange() {
+ // 工具栏描边
+ int nStroke = 5;
+
+ TypedArray taBorder = getContext().obtainStyledAttributes(new int[]{R.attr.borderCornerRadius});
+ float cornerRadius = taBorder.getDimension(0, 6 * getResources().getDisplayMetrics().density);
+ taBorder.recycle();
+
+ //分别为开始颜色,中间夜色,结束颜色
+ int colors0[] = { mEndColor , mCenterColor, mStartColor};
+ GradientDrawable gradientDrawable0;
+ array[2] = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, colors0);
+ gradientDrawable0 = array[2];
+ gradientDrawable0.setShape(GradientDrawable.RECTANGLE);
+ gradientDrawable0.setColors(colors0); //添加颜色组
+ gradientDrawable0.setGradientType(GradientDrawable.LINEAR_GRADIENT);//设置线性渐变
+ gradientDrawable0.setCornerRadius(cornerRadius);
+
+ int colors1[] = { mCenterColor , mCenterColor, mCenterColor };
+ GradientDrawable gradientDrawable1;
+ array[1] = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, colors1);
+ gradientDrawable1 = array[1];
+ gradientDrawable1.setShape(GradientDrawable.RECTANGLE);
+ gradientDrawable1.setColors(colors1); //添加颜色组
+ gradientDrawable1.setGradientType(GradientDrawable.LINEAR_GRADIENT);//设置线性渐变
+ gradientDrawable1.setCornerRadius(cornerRadius);
+
+ int colors2[] = { mEndColor, mCenterColor, mStartColor };
+ GradientDrawable gradientDrawable2;
+ array[0] = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, colors2);
+ gradientDrawable2 = array[0];
+ gradientDrawable2.setShape(GradientDrawable.RECTANGLE);
+ gradientDrawable2.setColors(colors2); //添加颜色组
+ gradientDrawable2.setGradientType(GradientDrawable.LINEAR_GRADIENT);//设置线性渐变
+ gradientDrawable2.setCornerRadius(cornerRadius);
+
+ ld = new LayerDrawable(array); //参数为上面的Drawable数组
+ ld.setLayerInset(2, nStroke * 2, nStroke * 2, getWidth() + nStroke * 2, getHeight() + nStroke * 2);
+ ld.setLayerInset(1, nStroke, nStroke, getWidth() + nStroke, getHeight() + nStroke);
+ ld.setLayerInset(0, 0, 0, getWidth(), getHeight());
+
+ setBackgroundDrawable(ld);
+ setTitleTextColor(mTitleTextColor);
+ setSubtitleTextColor(mTitleTextColor);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/ATickProgressBar.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/ATickProgressBar.java
new file mode 100644
index 0000000..ddba22b
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/ATickProgressBar.java
@@ -0,0 +1,55 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:56:38
+ * @Describe ATickProgressBar
+ */
+import android.content.Context;
+import android.os.Handler;
+import android.util.AttributeSet;
+import android.widget.ProgressBar;
+
+public class ATickProgressBar extends ProgressBar {
+
+ public static final String TAG = "ATickProgressBar";
+
+ int mnStepDistantce = 100 / 10;
+ int mnProgress = 0;
+
+ public ATickProgressBar(Context context) {
+ super(context);
+
+ }
+
+ public ATickProgressBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setProgress(50);
+ }
+
+ public int stepOnTick(int nStepDistantce) {
+ if (mnProgress < 100) {
+ int nProgressOld = mnProgress;
+ mnProgress += nStepDistantce;
+ new Handler().postDelayed(new Runnable(){
+
+ @Override
+ public void run() {
+ ;
+ }
+ }, 1000);
+ return nProgressOld;
+ } else {
+ return mnProgress;
+ }
+ }
+
+ /*@Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ int nWidthSize = MeasureSpec.getSize(widthMeasureSpec);
+ int nHeightSize = MeasureSpec.getSize(heightMeasureSpec);
+
+ setMeasuredDimension(nWidthSize, nHeightSize);
+ }*/
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/AToolbar.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/AToolbar.java
new file mode 100644
index 0000000..dac29ae
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/AToolbar.java
@@ -0,0 +1,96 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/07/16 01:58:01
+ * @Describe AToolbar
+ */
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.LayerDrawable;
+import android.util.AttributeSet;
+import android.widget.Toolbar;
+import cc.winboll.studio.libaes.R;
+
+public class AToolbar extends Toolbar {
+
+ public static final String TAG = "AToolbar";
+
+ int mTitleTextColor;
+ int mStartColor;
+ int mCenterColor;
+ int mEndColor;
+ LayerDrawable ld;
+ GradientDrawable[] array = new GradientDrawable[3];
+
+ public AToolbar(Context context) {
+ super(context);
+ }
+
+ public AToolbar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AToolbar, R.attr.aToolbar, 0);
+ mTitleTextColor = a.getColor(R.styleable.AToolbar_attrAToolbarTitleTextColor, Color.GREEN);
+ mStartColor = a.getColor(R.styleable.AToolbar_attrAToolbarStartColor, Color.BLUE);
+ mCenterColor = a.getColor(R.styleable.AToolbar_attrAToolbarCenterColor, Color.RED);
+ mEndColor = a.getColor(R.styleable.AToolbar_attrAToolbarEndColor, Color.YELLOW);
+ // 返回一个绑定资源结束的信号给资源
+ a.recycle();
+
+ notifyColorChange();
+ }
+
+ public AToolbar(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ void notifyColorChange() {
+ // 工具栏描边
+ int nStroke = 5;
+
+ TypedArray taBorder = getContext().obtainStyledAttributes(new int[]{R.attr.borderCornerRadius});
+ float cornerRadius = taBorder.getDimension(0, 6 * getResources().getDisplayMetrics().density);
+ taBorder.recycle();
+
+ //分别为开始颜色,中间夜色,结束颜色
+ int colors0[] = { mEndColor , mCenterColor, mStartColor};
+ GradientDrawable gradientDrawable0;
+ array[2] = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, colors0);
+ gradientDrawable0 = array[2];
+ gradientDrawable0.setShape(GradientDrawable.RECTANGLE);
+ gradientDrawable0.setColors(colors0); //添加颜色组
+ gradientDrawable0.setGradientType(GradientDrawable.LINEAR_GRADIENT);//设置线性渐变
+ gradientDrawable0.setCornerRadius(cornerRadius);
+
+ int colors1[] = { mCenterColor , mCenterColor, mCenterColor };
+ GradientDrawable gradientDrawable1;
+ array[1] = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, colors1);
+ gradientDrawable1 = array[1];
+ gradientDrawable1.setShape(GradientDrawable.RECTANGLE);
+ gradientDrawable1.setColors(colors1); //添加颜色组
+ gradientDrawable1.setGradientType(GradientDrawable.LINEAR_GRADIENT);//设置线性渐变
+ gradientDrawable1.setCornerRadius(cornerRadius);
+
+ int colors2[] = { mEndColor, mCenterColor, mStartColor };
+ GradientDrawable gradientDrawable2;
+ array[0] = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, colors2);
+ gradientDrawable2 = array[0];
+ gradientDrawable2.setShape(GradientDrawable.RECTANGLE);
+ gradientDrawable2.setColors(colors2); //添加颜色组
+ gradientDrawable2.setGradientType(GradientDrawable.LINEAR_GRADIENT);//设置线性渐变
+ gradientDrawable2.setCornerRadius(cornerRadius);
+
+
+ ld = new LayerDrawable(array); //参数为上面的Drawable数组
+ ld.setLayerInset(2, nStroke * 2, nStroke * 2, getWidth() + nStroke * 2, getHeight() + nStroke * 2);
+ ld.setLayerInset(1, nStroke, nStroke, getWidth() + nStroke, getHeight() + nStroke);
+ ld.setLayerInset(0, 0, 0, getWidth(), getHeight());
+
+ setBackgroundDrawable(ld);
+ setTitleTextColor(mTitleTextColor);
+ setSubtitleTextColor(mTitleTextColor);
+ }
+}
diff --git a/libaes/src/main/java/cc/winboll/studio/libaes/views/AboutView.java b/libaes/src/main/java/cc/winboll/studio/libaes/views/AboutView.java
new file mode 100644
index 0000000..9f5ed56
--- /dev/null
+++ b/libaes/src/main/java/cc/winboll/studio/libaes/views/AboutView.java
@@ -0,0 +1,379 @@
+package cc.winboll.studio.libaes.views;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/03/24 15:08:52
+ * @Describe WinBoLL应用介绍视图
+ */
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.TypedArray;
+import android.net.Uri;
+import android.os.Message;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import cc.winboll.studio.libaes.R;
+import cc.winboll.studio.libaes.dialogs.YesNoAlertDialog;
+import cc.winboll.studio.libaes.models.APPInfo;
+import cc.winboll.studio.libaes.utils.AppVersionUtils;
+import cc.winboll.studio.libaes.utils.PrefUtils;
+import cc.winboll.studio.libaes.utils.WinBoLLActivityManager;
+import cc.winboll.studio.libappbase.GlobalApplication;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.ToastUtils;
+import java.io.IOException;
+import mehdi.sakout.aboutpage.AboutPage;
+import mehdi.sakout.aboutpage.Element;
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.Credentials;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class AboutView extends LinearLayout {
+
+ public static final String TAG = "AboutView";
+
+ public static final int MSG_APPUPDATE_CHECKED = 0;
+
+ static Context _mContext;
+ APPInfo mAPPInfo;
+
+ //WinBoLLServiceStatusView mWinBoLLServiceStatusView;
+ OnRequestDevUserInfoAutofillListener mOnRequestDevUserInfoAutofillListener;
+ String mszAppName = "";
+ String mszAppAPKFolderName = "";
+ String mszAppAPKName = "";
+ String mszAppGitName = "";
+ String mszAppVersionName = "";
+ String mszCurrentAppPackageName = "";
+ boolean mIsAddDebugTools;
+ volatile String mszNewestAppPackageName = "";
+ String mszAppDescription = "";
+ String mszHomePage = "";
+ String mszGitea = "";
+ int mnAppIcon = 0;
+ String mszWinBoLLServerHost;
+ String mszReleaseAPKName;
+ EditText metDevUserName;
+ EditText metDevUserPassword;
+
+ public AboutView(Context context, APPInfo appInfo) {
+ super(context);
+ _mContext = context;
+
+ setAPPInfo(appInfo);
+ initView(context);
+ }
+
+ public AboutView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ _mContext = context;
+
+ initView(context, attrs);
+ }
+
+ public void setAPPInfo(APPInfo appInfo) {
+ mAPPInfo = appInfo;
+ }
+
+ APPInfo createAppInfo(Context context, AttributeSet attrs) {
+ APPInfo appInfo = new APPInfo();
+ TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.AboutView);
+ appInfo.setAppName(typedArray.getString(R.styleable.AboutView_app_name));
+ appInfo.setAppAPKFolderName(typedArray.getString(R.styleable.AboutView_app_apkfoldername));
+ appInfo.setAppAPKName(typedArray.getString(R.styleable.AboutView_app_apkname));
+ appInfo.setAppGitName(typedArray.getString(R.styleable.AboutView_app_gitname));
+ appInfo.setAppGitOwner(typedArray.getString(R.styleable.AboutView_app_gitowner));
+ appInfo.setAppGitAPPBranch(typedArray.getString(R.styleable.AboutView_app_gitappbranch));
+ appInfo.setAppGitAPPSubProjectFolder(typedArray.getString(R.styleable.AboutView_app_gitappsubprojectfolder));
+ appInfo.setAppDescription(typedArray.getString(R.styleable.AboutView_appdescription));
+ appInfo.setAppIcon(typedArray.getResourceId(R.styleable.AboutView_appicon, R.drawable.ic_winboll));
+ appInfo.setIsAddDebugTools(typedArray.getBoolean(R.styleable.AboutView_is_adddebugtools, false));
+ // 返回一个绑定资源结束的信号给资源
+ typedArray.recycle();
+ return appInfo;
+ }
+
+ void initView(Context context) {
+ mszAppName = mAPPInfo.getAppName();
+ mszAppAPKFolderName = mAPPInfo.getAppAPKFolderName();
+ mszAppAPKName = mAPPInfo.getAppAPKName();
+ mszAppGitName = mAPPInfo.getAppGitName();
+ mszAppDescription = mAPPInfo.getAppDescription();
+ mnAppIcon = mAPPInfo.getAppIcon();
+
+ mszWinBoLLServerHost = GlobalApplication.isDebugging() ? "https://yun-preivew.winboll.cc": "https://yun.winboll.cc";
+
+ try {
+ mszAppVersionName = _mContext.getPackageManager().getPackageInfo(_mContext.getPackageName(), 0).versionName;
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ }
+ mszCurrentAppPackageName = mszAppAPKName + "_" + mszAppVersionName + ".apk";
+ mszHomePage = mAPPInfo.getAppHomePage();
+ //mszHomePage = mszWinBoLLServerHost + "/studio/details.php?app=" + mszAppAPKFolderName;
+ if (mAPPInfo.getAppGitAPPBranch().equals("")) {
+ mszGitea = "https://gitea.winboll.cc/" + mAPPInfo.getAppGitOwner() + "/" + mszAppGitName;
+ } else {
+ mszGitea = "https://gitea.winboll.cc/" + mAPPInfo.getAppGitOwner() + "/" + mszAppGitName + "/src/branch/" + mAPPInfo.getAppGitAPPBranch() + "/" + mAPPInfo.getAppGitAPPSubProjectFolder();
+ }
+
+ addView(createAboutPage());
+
+ // 初始化标题栏
+ //setSubtitle(getContext().getString(R.string.text_about));
+
+// LinearLayout llMain = findViewById(R.id.viewaboutLinearLayout1);
+// llMain.addView(createAboutPage());
+
+ // 就读取正式版应用包版本号,设置 Release 应用包文件名
+ String szReleaseAppVersionName = "";
+ try {
+ //LogUtils.d(TAG, String.format("mContext.getPackageName() %s", mContext.getPackageName()));
+ String szSubBetaSuffix = subBetaSuffix(_mContext.getPackageName());
+ //LogUtils.d(TAG, String.format("szSubBetaSuffix : %s", szSubBetaSuffix));
+ szReleaseAppVersionName = _mContext.getPackageManager().getPackageInfo(szSubBetaSuffix, 0).versionName;
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ }
+ mszReleaseAPKName = mszAppAPKName + "_" + szReleaseAppVersionName + ".apk";
+
+ }
+
+ void initView(Context context, AttributeSet attrs) {
+ mAPPInfo = createAppInfo(context, attrs);
+ initView(context);
+ }
+
+ public static String subBetaSuffix(String input) {
+ if (input.endsWith(".beta")) {
+ return input.substring(0, input.length() - ".beta".length());
+ }
+ return input;
+ }
+
+ android.os.Handler mHandler = new android.os.Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ super.handleMessage(msg);
+ switch (msg.what) {
+ case MSG_APPUPDATE_CHECKED : {
+ /*//检查当前应用包文件名是否是测试版,如果是就忽略检查
+ if(mszCurrentAppPackageName.matches(".*_\\d+\\.\\d+\\.\\d+-beta.*\\.apk")) {
+ ToastUtils.show("APP is the beta Version. Version check ignore.");
+ return;
+ }*/
+
+// if (!AppVersionUtils.isHasNewStageReleaseVersion(mszReleaseAPKName, mszNewestAppPackageName)) {
+// ToastUtils.delayedShow("Current app is the newest.", 5000);
+// }
+ if (!AppVersionUtils.isHasNewVersion2(mszCurrentAppPackageName, mszNewestAppPackageName)) {
+ ToastUtils.show("Current app is the newest.");
+ } else {
+ String szMsg = "Current app is :\n[ " + mszCurrentAppPackageName
+ + " ]\nThe last app is :\n[ " + mszNewestAppPackageName
+ + " ]\nIs download the last app?";
+ YesNoAlertDialog.show(_mContext, "Application Update Prompt", szMsg, mIsDownlaodUpdateListener);
+ }
+ break;
+ }
+ }
+ }
+ };
+
+ protected View createAboutPage() {
+ // 定义 GitWeb 按钮
+ //
+ Element elementGitWeb = new Element(_mContext.getString(R.string.gitea_home), R.drawable.ic_winboll);
+ elementGitWeb.setOnClickListener(mGitWebOnClickListener);
+ // 定义检查更新按钮
+ //
+ /*Element elementAppUpdate = new Element(_mContext.getString(R.string.app_update), R.drawable.ic_winboll);
+ elementAppUpdate.setOnClickListener(mAppUpdateOnClickListener);
+ */
+
+ String szAppInfo = "";
+ try {
+ szAppInfo = mszAppName + " "
+ + _mContext.getPackageManager().getPackageInfo(_mContext.getPackageName(), 0).versionName
+ + "\n" + mszAppDescription;
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ }
+ AboutPage aboutPage = new AboutPage(_mContext);
+ aboutPage.setDescription(szAppInfo)
+ //.isRTL(false)
+ //.setCustomFont(String) // or Typeface
+ .setImage(mnAppIcon)
+ //.addItem(versionElement)
+ //.addItem(adsElement)
+ //.addGroup("Connect with us")
+ .addEmail("WinBoLLStudio")
+ .addWebsite(mszHomePage)
+ .addItem(elementGitWeb);
+ //.addItem(elementAppUpdate);
+ //.addFacebook("the.medy")
+ //.addTwitter("medyo80")
+ //.addYoutube("UCdPQtdWIsg7_pi4mrRu46vA")
+ //.addPlayStore("com.ideashower.readitlater.pro")
+ //.addGitHub("medyo")
+ //.addInstagram("medyo80")
+ //.create();
+
+ /*if (mAPPInfo.isAddDebugTools()) {
+ // 定义应用调试按钮
+ //
+ Element elementAppMode;
+ if (GlobalApplication.isDebugging()) {
+ elementAppMode = new Element(_mContext.getString(R.string.app_normal), R.drawable.ic_winboll);
+ elementAppMode.setOnClickListener(mAppNormalOnClickListener);
+ } else {
+ elementAppMode = new Element(_mContext.getString(R.string.app_debug), R.drawable.ic_winboll);
+ elementAppMode.setOnClickListener(mAppDebugOnClickListener);
+ }
+ aboutPage.addItem(elementAppMode);
+ }*/
+
+ return aboutPage.create();
+ }
+
+ View.OnClickListener mAppDebugOnClickListener = new View.OnClickListener(){
+ @Override
+ public void onClick(View view) {
+ //ToastUtils.show("mAppDebugOnClickListener");
+ setApp2DebugMode(_mContext);
+ }
+ };
+
+ View.OnClickListener mAppNormalOnClickListener = new View.OnClickListener(){
+ @Override
+ public void onClick(View view) {
+ //ToastUtils.show("mAppNormalOnClickListener");
+ setApp2NormalMode(_mContext);
+ }
+ };
+
+ public static void setApp2DebugMode(Context context) {
+ Intent intent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
+ if (intent != null) {
+ //intent.setAction(cc.winboll.studio.libapputils.intent.action.DEBUGVIEW);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ GlobalApplication.setIsDebugging(true);
+ GlobalApplication.saveDebugStatus((GlobalApplication)_mContext.getApplicationContext());
+
+ WinBoLLActivityManager.getInstance().finishAll();
+ context.startActivity(intent);
+ }
+ }
+
+ public static void setApp2NormalMode(Context context) {
+ Intent intent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
+ if (intent != null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ GlobalApplication.setIsDebugging(false);
+ GlobalApplication.saveDebugStatus((GlobalApplication)_mContext.getApplicationContext());
+
+ WinBoLLActivityManager.getInstance().finishAll();
+ context.startActivity(intent);
+ }
+ }
+
+ View.OnClickListener mGitWebOnClickListener = new View.OnClickListener(){
+ @Override
+ public void onClick(View view) {
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(mszGitea));
+ _mContext.startActivity(browserIntent);
+ }
+ };
+
+ View.OnClickListener mAppUpdateOnClickListener = new View.OnClickListener(){
+ @Override
+ public void onClick(View view) {
+ ToastUtils.show("Start app update checking.");
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ String szUrl = mszWinBoLLServerHost + "/studio/details.php?app=" + mszAppAPKFolderName;
+ // 构建包含认证信息的请求
+ String credential = "";
+ if (GlobalApplication.isDebugging()) {
+ credential = Credentials.basic(metDevUserName.getText().toString(), metDevUserPassword.getText().toString());
+ PrefUtils.saveString(_mContext, "metDevUserName", metDevUserName.getText().toString());
+ PrefUtils.saveString(_mContext, "metDevUserPassword", metDevUserPassword.getText().toString());
+ } else {
+ String username = "WinBoLL";
+ String password = "WinBoLLPowerByZhanGSKen";
+ credential = Credentials.basic(username, password);
+ }
+
+ Request request = new Request.Builder()
+ .url(szUrl)
+ .header("Accept", "text/plain") // 设置正确的Content-Type头
+ .header("Authorization", credential)
+ .build();
+ OkHttpClient client = new OkHttpClient();
+ Call call = client.newCall(request);
+ call.enqueue(new Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ // 处理网络请求失败
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) throws IOException {
+ if (!response.isSuccessful()) {
+ LogUtils.d(TAG, "Unexpected code " + response, Thread.currentThread().getStackTrace());
+ return;
+ }
+
+ try {
+ // 读取响应体作为字符串,注意这里可能需要解码
+ String text = response.body().string();
+ org.jsoup.nodes.Document doc = org.jsoup.Jsoup.parse(text);
+ LogUtils.v(TAG, doc.text());
+
+ // 使用id选择器找到具有特定id的元素
+ org.jsoup.nodes.Element elementWithId = doc.select("#LastRelease").first(); // 获取第一个匹配的元素
+
+ // 提取并打印元素的文本内容
+ mszNewestAppPackageName = elementWithId.text();
+ //ToastUtils.delayedShow(text + "\n" + mszNewestAppPackageName, 5000);
+
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_APPUPDATE_CHECKED));
+ } catch (Exception e) {
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ }
+ }
+ });
+ }
+ }).start();
+ }
+ };
+
+ YesNoAlertDialog.OnDialogResultListener mIsDownlaodUpdateListener = new YesNoAlertDialog.OnDialogResultListener() {
+ @Override
+ public void onYes() {
+ String szUrl = mszWinBoLLServerHost + "/studio/download.php?appname=" + mszAppAPKFolderName + "&apkname=" + mszNewestAppPackageName;
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(szUrl));
+ _mContext.startActivity(browserIntent);
+ }
+
+ @Override
+ public void onNo() {
+ }
+ };
+
+ public interface OnRequestDevUserInfoAutofillListener {
+ void requestAutofill(EditText etDevUserName, EditText etDevUserPassword);
+ }
+
+ public void setOnRequestDevUserInfoAutofillListener(OnRequestDevUserInfoAutofillListener l) {
+ mOnRequestDevUserInfoAutofillListener = l;
+ }
+}
diff --git a/libaes/src/main/res/anim/normal_dialog_enter_center.xml b/libaes/src/main/res/anim/normal_dialog_enter_center.xml
new file mode 100644
index 0000000..7a6ac10
--- /dev/null
+++ b/libaes/src/main/res/anim/normal_dialog_enter_center.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/anim/normal_dialog_enter_corner.xml b/libaes/src/main/res/anim/normal_dialog_enter_corner.xml
new file mode 100644
index 0000000..edfc076
--- /dev/null
+++ b/libaes/src/main/res/anim/normal_dialog_enter_corner.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/libaes/src/main/res/anim/normal_dialog_exit_center.xml b/libaes/src/main/res/anim/normal_dialog_exit_center.xml
new file mode 100644
index 0000000..4d69f65
--- /dev/null
+++ b/libaes/src/main/res/anim/normal_dialog_exit_center.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/anim/normal_dialog_exit_corner.xml b/libaes/src/main/res/anim/normal_dialog_exit_corner.xml
new file mode 100644
index 0000000..4b9d1fc
--- /dev/null
+++ b/libaes/src/main/res/anim/normal_dialog_exit_corner.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/libaes/src/main/res/drawable/acard_frame_main.xml b/libaes/src/main/res/drawable/acard_frame_main.xml
new file mode 100644
index 0000000..ee332b2
--- /dev/null
+++ b/libaes/src/main/res/drawable/acard_frame_main.xml
@@ -0,0 +1,50 @@
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/atoolbar_frame.xml b/libaes/src/main/res/drawable/atoolbar_frame.xml
new file mode 100644
index 0000000..198e122
--- /dev/null
+++ b/libaes/src/main/res/drawable/atoolbar_frame.xml
@@ -0,0 +1,50 @@
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/bg_container_border.xml b/libaes/src/main/res/drawable/bg_container_border.xml
new file mode 100644
index 0000000..09c5bdf
--- /dev/null
+++ b/libaes/src/main/res/drawable/bg_container_border.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/bg_shadow.xml b/libaes/src/main/res/drawable/bg_shadow.xml
new file mode 100644
index 0000000..0cafd81
--- /dev/null
+++ b/libaes/src/main/res/drawable/bg_shadow.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/btn_style.xml b/libaes/src/main/res/drawable/btn_style.xml
new file mode 100644
index 0000000..7c6bb83
--- /dev/null
+++ b/libaes/src/main/res/drawable/btn_style.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/drawable/default_shape.xml b/libaes/src/main/res/drawable/default_shape.xml
new file mode 100644
index 0000000..8a405ca
--- /dev/null
+++ b/libaes/src/main/res/drawable/default_shape.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/ic_arrow_left_right_bold.xml b/libaes/src/main/res/drawable/ic_arrow_left_right_bold.xml
new file mode 100644
index 0000000..8d6dc41
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_arrow_left_right_bold.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/drawable/ic_arrow_up_circle_outline.xml b/libaes/src/main/res/drawable/ic_arrow_up_circle_outline.xml
new file mode 100644
index 0000000..38c794a
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_arrow_up_circle_outline.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/drawable/ic_call.xml b/libaes/src/main/res/drawable/ic_call.xml
new file mode 100644
index 0000000..c5802bb
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_call.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/drawable/ic_dev_connected.xml b/libaes/src/main/res/drawable/ic_dev_connected.xml
new file mode 100644
index 0000000..1fb2f26
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_dev_connected.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/drawable/ic_dev_disconnected.xml b/libaes/src/main/res/drawable/ic_dev_disconnected.xml
new file mode 100644
index 0000000..4267975
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_dev_disconnected.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/drawable/ic_email.xml b/libaes/src/main/res/drawable/ic_email.xml
new file mode 100644
index 0000000..d526b26
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_email.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/ic_email_alert.xml b/libaes/src/main/res/drawable/ic_email_alert.xml
new file mode 100644
index 0000000..f3ed613
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_email_alert.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/ic_launcher.xml b/libaes/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..f269b7e
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
diff --git a/libaes/src/main/res/drawable/ic_launcher_background.xml b/libaes/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..9486190
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/ic_launcher_foreground.xml b/libaes/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..872b04e
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/libaes/src/main/res/drawable/ic_winboll.xml b/libaes/src/main/res/drawable/ic_winboll.xml
new file mode 100644
index 0000000..f269b7e
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_winboll.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
diff --git a/libaes/src/main/res/drawable/ic_winboll_store.png b/libaes/src/main/res/drawable/ic_winboll_store.png
new file mode 100644
index 0000000..e8bb856
Binary files /dev/null and b/libaes/src/main/res/drawable/ic_winboll_store.png differ
diff --git a/libaes/src/main/res/drawable/ic_winbollbeta.xml b/libaes/src/main/res/drawable/ic_winbollbeta.xml
new file mode 100644
index 0000000..f4876a0
--- /dev/null
+++ b/libaes/src/main/res/drawable/ic_winbollbeta.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/ohpcts_frame.xml b/libaes/src/main/res/drawable/ohpcts_frame.xml
new file mode 100644
index 0000000..77eacff
--- /dev/null
+++ b/libaes/src/main/res/drawable/ohpcts_frame.xml
@@ -0,0 +1,43 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/pressed_shape.xml b/libaes/src/main/res/drawable/pressed_shape.xml
new file mode 100644
index 0000000..ed448af
--- /dev/null
+++ b/libaes/src/main/res/drawable/pressed_shape.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/shape_gradient.xml b/libaes/src/main/res/drawable/shape_gradient.xml
new file mode 100644
index 0000000..c164fe9
--- /dev/null
+++ b/libaes/src/main/res/drawable/shape_gradient.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/toast_frame.xml b/libaes/src/main/res/drawable/toast_frame.xml
new file mode 100644
index 0000000..c4b8f8a
--- /dev/null
+++ b/libaes/src/main/res/drawable/toast_frame.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/view_border.xml b/libaes/src/main/res/drawable/view_border.xml
new file mode 100644
index 0000000..58b374a
--- /dev/null
+++ b/libaes/src/main/res/drawable/view_border.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/winboll_help.xml b/libaes/src/main/res/drawable/winboll_help.xml
new file mode 100644
index 0000000..564175f
--- /dev/null
+++ b/libaes/src/main/res/drawable/winboll_help.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/winboll_logo.xml b/libaes/src/main/res/drawable/winboll_logo.xml
new file mode 100644
index 0000000..ea28987
--- /dev/null
+++ b/libaes/src/main/res/drawable/winboll_logo.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/drawable/winboll_point.xml b/libaes/src/main/res/drawable/winboll_point.xml
new file mode 100644
index 0000000..48028cc
--- /dev/null
+++ b/libaes/src/main/res/drawable/winboll_point.xml
@@ -0,0 +1,20 @@
+
+
+
+
diff --git a/libaes/src/main/res/layout/activity_about.xml b/libaes/src/main/res/layout/activity_about.xml
new file mode 100644
index 0000000..337d98e
--- /dev/null
+++ b/libaes/src/main/res/layout/activity_about.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/activity_drawer.xml b/libaes/src/main/res/layout/activity_drawer.xml
new file mode 100644
index 0000000..9ce0a85
--- /dev/null
+++ b/libaes/src/main/res/layout/activity_drawer.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/activity_drawerfragment.xml b/libaes/src/main/res/layout/activity_drawerfragment.xml
new file mode 100644
index 0000000..6c314df
--- /dev/null
+++ b/libaes/src/main/res/layout/activity_drawerfragment.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/activity_secondarylibrary.xml b/libaes/src/main/res/layout/activity_secondarylibrary.xml
new file mode 100644
index 0000000..ac1df89
--- /dev/null
+++ b/libaes/src/main/res/layout/activity_secondarylibrary.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/activity_test_aboutfragment.xml b/libaes/src/main/res/layout/activity_test_aboutfragment.xml
new file mode 100644
index 0000000..3c30c2f
--- /dev/null
+++ b/libaes/src/main/res/layout/activity_test_aboutfragment.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/activity_testasupporttoolbar.xml b/libaes/src/main/res/layout/activity_testasupporttoolbar.xml
new file mode 100644
index 0000000..c8214e7
--- /dev/null
+++ b/libaes/src/main/res/layout/activity_testasupporttoolbar.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/activity_testatoolbar.xml b/libaes/src/main/res/layout/activity_testatoolbar.xml
new file mode 100644
index 0000000..d72459c
--- /dev/null
+++ b/libaes/src/main/res/layout/activity_testatoolbar.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/dialog_privacy_agreement.xml b/libaes/src/main/res/layout/dialog_privacy_agreement.xml
new file mode 100644
index 0000000..438d992
--- /dev/null
+++ b/libaes/src/main/res/layout/dialog_privacy_agreement.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/dialog_storagepath.xml b/libaes/src/main/res/layout/dialog_storagepath.xml
new file mode 100644
index 0000000..9a459c4
--- /dev/null
+++ b/libaes/src/main/res/layout/dialog_storagepath.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/fragment_abutton.xml b/libaes/src/main/res/layout/fragment_abutton.xml
new file mode 100644
index 0000000..d608b7d
--- /dev/null
+++ b/libaes/src/main/res/layout/fragment_abutton.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/fragment_secondarylibrary.xml b/libaes/src/main/res/layout/fragment_secondarylibrary.xml
new file mode 100644
index 0000000..b47f0ae
--- /dev/null
+++ b/libaes/src/main/res/layout/fragment_secondarylibrary.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/fragment_test1.xml b/libaes/src/main/res/layout/fragment_test1.xml
new file mode 100644
index 0000000..112fb47
--- /dev/null
+++ b/libaes/src/main/res/layout/fragment_test1.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/fragment_test2.xml b/libaes/src/main/res/layout/fragment_test2.xml
new file mode 100644
index 0000000..3b0e457
--- /dev/null
+++ b/libaes/src/main/res/layout/fragment_test2.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/fragment_viewpage.xml b/libaes/src/main/res/layout/fragment_viewpage.xml
new file mode 100644
index 0000000..923dfac
--- /dev/null
+++ b/libaes/src/main/res/layout/fragment_viewpage.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/listview_drawermenu.xml b/libaes/src/main/res/layout/listview_drawermenu.xml
new file mode 100644
index 0000000..731b20a
--- /dev/null
+++ b/libaes/src/main/res/layout/listview_drawermenu.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/view_adsbanner.xml b/libaes/src/main/res/layout/view_adsbanner.xml
new file mode 100644
index 0000000..db95803
--- /dev/null
+++ b/libaes/src/main/res/layout/view_adsbanner.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/view_adscontrol.xml b/libaes/src/main/res/layout/view_adscontrol.xml
new file mode 100644
index 0000000..23a29b3
--- /dev/null
+++ b/libaes/src/main/res/layout/view_adscontrol.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/viewpage_acard.xml b/libaes/src/main/res/layout/viewpage_acard.xml
new file mode 100644
index 0000000..e15effb
--- /dev/null
+++ b/libaes/src/main/res/layout/viewpage_acard.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/viewpage_aohpctccard.xml b/libaes/src/main/res/layout/viewpage_aohpctccard.xml
new file mode 100644
index 0000000..7ea16e4
--- /dev/null
+++ b/libaes/src/main/res/layout/viewpage_aohpctccard.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/viewpage_aohpctcsb.xml b/libaes/src/main/res/layout/viewpage_aohpctcsb.xml
new file mode 100644
index 0000000..b94e390
--- /dev/null
+++ b/libaes/src/main/res/layout/viewpage_aohpctcsb.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/layout/viewpage_atickprogressbar.xml b/libaes/src/main/res/layout/viewpage_atickprogressbar.xml
new file mode 100644
index 0000000..b84a703
--- /dev/null
+++ b/libaes/src/main/res/layout/viewpage_atickprogressbar.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/menu/menu_qrcode_long_click.xml b/libaes/src/main/res/menu/menu_qrcode_long_click.xml
new file mode 100644
index 0000000..6b423fa
--- /dev/null
+++ b/libaes/src/main/res/menu/menu_qrcode_long_click.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/libaes/src/main/res/menu/toolbar_appdebug.xml b/libaes/src/main/res/menu/toolbar_appdebug.xml
new file mode 100644
index 0000000..54419f2
--- /dev/null
+++ b/libaes/src/main/res/menu/toolbar_appdebug.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/libaes/src/main/res/menu/toolbar_apptheme.xml b/libaes/src/main/res/menu/toolbar_apptheme.xml
new file mode 100644
index 0000000..f9fca32
--- /dev/null
+++ b/libaes/src/main/res/menu/toolbar_apptheme.xml
@@ -0,0 +1,29 @@
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/menu/toolbar_drawerbase.xml b/libaes/src/main/res/menu/toolbar_drawerbase.xml
new file mode 100644
index 0000000..66df226
--- /dev/null
+++ b/libaes/src/main/res/menu/toolbar_drawerbase.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/libaes/src/main/res/menu/toolbar_secondarylibrary.xml b/libaes/src/main/res/menu/toolbar_secondarylibrary.xml
new file mode 100644
index 0000000..72cdd43
--- /dev/null
+++ b/libaes/src/main/res/menu/toolbar_secondarylibrary.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/libaes/src/main/res/values-night/colors.xml b/libaes/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..3935b96
--- /dev/null
+++ b/libaes/src/main/res/values-night/colors.xml
@@ -0,0 +1,33 @@
+
+
+
+ #FFFFFFFF
+ #FF03AB4E
+ #FF027C39
+ #FF3DDC84
+ #FFFFFB8D
+ #FFA9A9A9
+ #FF000000
+ #FFFFFFFF
+ #FF7D3F12
+ #FFCC6E2B
+ #FFF4B98F
+
+ @color/colorPrimaryDark
+ @color/colorPrimary
+ @color/colorAccent
+
+ @color/colorAccent
+ @color/colorPrimary
+
+ @color/colorAccent
+ @color/colorPrimary
+ @color/colorPrimaryDark
+
+ #FF03AB4E
+ #FFFFFFFF
+ #FF2C2C2C
+ #FFFFFFFF
+ #FFFF0000
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/values-night/styles.xml b/libaes/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..020baff
--- /dev/null
+++ b/libaes/src/main/res/values-night/styles.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/values-zh/string.xml b/libaes/src/main/res/values-zh/string.xml
new file mode 100644
index 0000000..2d5fdbc
--- /dev/null
+++ b/libaes/src/main/res/values-zh/string.xml
@@ -0,0 +1,17 @@
+
+
+
+ libaes
+ 云宝APP应用安卓元素类库示例。源码仅供调试参考,请勿直接引用。(WinBoLL APP application Android element class library example. The source is just for demo debug test, Do not quote directly.)
+ https://winboll.cc/aes
+ 关于
+ 应用主题
+ 默认主题
+ 深奥主题
+ 天空主题
+ 辉煌主题
+ 智芋主题
+ 梦箩主题
+ 黑白主题
+
+
diff --git a/libaes/src/main/res/values/attrs.xml b/libaes/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..51786dd
--- /dev/null
+++ b/libaes/src/main/res/values/attrs.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/values/colors.xml b/libaes/src/main/res/values/colors.xml
new file mode 100644
index 0000000..5c0a9cc
--- /dev/null
+++ b/libaes/src/main/res/values/colors.xml
@@ -0,0 +1,33 @@
+
+
+
+ #FF000000
+ #FF03AB4E
+ #FF027C39
+ #FF3DDC84
+ #FFFFFB8D
+ #FFA9A9A9
+ #FF000000
+ #FFFFFFFF
+ #FF7D3F12
+ #FFCC6E2B
+ #FFF4B98F
+
+ @color/colorPrimaryDark
+ @color/colorPrimary
+ @color/colorAccent
+
+ @color/colorAccent
+ @color/colorPrimary
+
+ @color/colorAccent
+ @color/colorPrimary
+ @color/colorPrimaryDark
+
+ #FF03AB4E
+ #FFFFFFFF
+ #FFFFFFFF
+ #FF000000
+ #FFFF0000
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/values/dimens.xml b/libaes/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..859a2ab
--- /dev/null
+++ b/libaes/src/main/res/values/dimens.xml
@@ -0,0 +1,16 @@
+
+
+
+ - 900
+ - -900
+
+
+
diff --git a/libaes/src/main/res/values/strings.xml b/libaes/src/main/res/values/strings.xml
new file mode 100644
index 0000000..a5b7663
--- /dev/null
+++ b/libaes/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ libaes
+ 云宝APP应用安卓元素类库示例。源码仅供调试参考,请勿直接引用。(WinBoLL APP application Android element class library example. The source is just for demo debug test, Do not quote directly.)
+ https://winboll.cc/libaes
+ About
+ AppTheme
+ DefaultTheme
+ DepthTheme
+ SkyTheme
+ GoldenTheme
+ BearingTheme
+ MemorTheme
+ TaoTheme
+
+ Click here is switch to Normal APP
+ Click here is switch to APP DEBUG
+ GITEA HOME
+ APP UPDATE
+
+
diff --git a/libaes/src/main/res/values/styles.xml b/libaes/src/main/res/values/styles.xml
new file mode 100644
index 0000000..8354a03
--- /dev/null
+++ b/libaes/src/main/res/values/styles.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libaes/src/main/res/xml/file_provider.xml b/libaes/src/main/res/xml/file_provider.xml
new file mode 100644
index 0000000..3e2adb3
--- /dev/null
+++ b/libaes/src/main/res/xml/file_provider.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/libaes/src/main/res/xml/network_security_config.xml b/libaes/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..ba3f407
--- /dev/null
+++ b/libaes/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ winboll.cc
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/.gitignore b/libappbase/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/libappbase/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/libappbase/build.gradle b/libappbase/build.gradle
new file mode 100644
index 0000000..2e7654e
--- /dev/null
+++ b/libappbase/build.gradle
@@ -0,0 +1,38 @@
+apply plugin: 'com.android.library'
+apply plugin: 'maven-publish'
+apply from: '../.winboll/winboll_lib_build.gradle'
+apply from: '../.winboll/winboll_lint_build.gradle'
+
+android {
+ // 适配MIUI12
+ compileSdkVersion 30
+ buildToolsVersion "30.0.3"
+
+ defaultConfig {
+ minSdkVersion 26
+ targetSdkVersion 30
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_7
+ targetCompatibility JavaVersion.VERSION_1_7
+ }
+}
+
+dependencies {
+ // 网络连接类库
+ api 'com.squareup.okhttp3:okhttp:4.4.1'
+ // Gson
+ api 'com.google.code.gson:gson:2.8.9'
+ // Html 解析
+ api 'org.jsoup:jsoup:1.13.1'
+ // 添加JSch依赖(SFTP核心,com.jcraft:jsch:0.1.54)
+ api 'com.jcraft:jsch:0.1.54'
+
+ api fileTree(dir: 'libs', include: ['*.jar'])
+}
diff --git a/libappbase/build.properties b/libappbase/build.properties
new file mode 100644
index 0000000..8d3d61f
--- /dev/null
+++ b/libappbase/build.properties
@@ -0,0 +1,8 @@
+#Created by .winboll/winboll_app_build.gradle
+#Tue May 12 09:16:45 HKT 2026
+stageCount=10
+libraryProject=libappbase
+baseVersion=15.20
+publishVersion=15.20.9
+buildCount=0
+baseBetaVersion=15.20.10
diff --git a/libappbase/proguard-rules.pro b/libappbase/proguard-rules.pro
new file mode 100644
index 0000000..536058a
--- /dev/null
+++ b/libappbase/proguard-rules.pro
@@ -0,0 +1,17 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in C:/tools/adt-bundle-windows-x86_64-20131030/sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
diff --git a/libappbase/src/main/AndroidManifest.xml b/libappbase/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bbfd3f3
--- /dev/null
+++ b/libappbase/src/main/AndroidManifest.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/APPModel.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/APPModel.java
new file mode 100644
index 0000000..d4dc9ca
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/APPModel.java
@@ -0,0 +1,138 @@
+package cc.winboll.studio.libappbase;
+
+import android.util.JsonReader;
+import android.util.JsonWriter;
+import java.io.IOException;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/11 20:01
+ * @Describe WinBoLL 应用全局数据模型类
+ * 继承自 BaseBean,用于存储和管理应用的核心配置信息(如调试状态),
+ * 支持 JSON 序列化/反序列化,便于数据持久化或跨组件传递
+ */
+public class APPModel extends BaseBean {
+
+ /**
+ * 日志打印标签,用于区分当前类的日志输出
+ */
+ public static final String TAG = "APPModel";
+
+ /**
+ * 应用调试状态标识
+ * true:应用处于调试模式(可输出详细日志、启用调试功能等)
+ * false:应用处于正式模式(关闭调试相关功能,优化性能)
+ */
+ private boolean isDebugging = false; // 修正拼写:原 isDebuging -> isDebugging(符合命名规范)
+
+ /**
+ * 无参构造方法
+ * 初始化调试状态为默认值:false(正式模式)
+ */
+ public APPModel() {
+ this.isDebugging = false;
+ }
+
+ /**
+ * 带参构造方法
+ * 可通过参数指定应用的初始调试状态
+ * @param isDebugging 初始调试状态(true:调试模式;false:正式模式)
+ */
+ public APPModel(boolean isDebugging) {
+ this.isDebugging = isDebugging;
+ }
+
+ /**
+ * 设置应用调试状态
+ * @param isDebugging 目标调试状态(true:开启调试;false:关闭调试)
+ */
+ public void setIsDebugging(boolean isDebugging) {
+ this.isDebugging = isDebugging;
+ }
+
+ /**
+ * 获取当前应用调试状态
+ * @return 调试状态(true:调试中;false:非调试)
+ */
+ public boolean isDebugging() {
+ return isDebugging;
+ }
+
+ /**
+ * 重写父类方法,返回当前类的全限定名
+ * 用于标识数据模型的类类型(可用于反射、序列化校验等场景)
+ * @return 类的全限定名(如:cc.winboll.studio.libappbase.APPModel)
+ */
+ @Override
+ public String getName() {
+ return APPModel.class.getName();
+ }
+
+ /**
+ * 重写父类方法,将当前模型的字段序列化到 JSON 中
+ * 用于将调试状态等核心数据转换为 JSON 格式(如持久化到文件、网络传输)
+ * @param jsonWriter JSON 写入器对象,用于输出 JSON 数据
+ * @throws IOException 当 JSON 写入失败时抛出(如流关闭、格式错误)
+ */
+ @Override
+ public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException {
+ // 先调用父类方法,序列化父类中的字段(若 BaseBean 有可序列化字段)
+ super.writeThisToJsonWriter(jsonWriter);
+ // 序列化当前类的调试状态字段:key 为 "isDebuging"(保持与原代码一致,避免兼容性问题),value 为当前状态
+ jsonWriter.name("isDebuging").value(isDebugging());
+ }
+
+ /**
+ * 重写父类方法,从 JSON 中解析字段并初始化当前对象
+ * 用于将 JSON 格式的配置数据解析为 APPModel 实例(如从文件读取、网络接收后解析)
+ * @param jsonReader JSON 读取器对象,用于读取 JSON 数据
+ * @param name 当前解析的 JSON 字段名
+ * @return true:字段解析成功;false:字段不属于当前类,需由调用者处理
+ * @throws IOException 当 JSON 读取失败时抛出(如流关闭、数据格式错误)
+ */
+ @Override
+ public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException {
+ // 先调用父类方法,解析父类中的字段(若 BaseBean 有可解析字段)
+ if (super.initObjectsFromJsonReader(jsonReader, name)) {
+ return true; // 父类已处理该字段,直接返回成功
+ } else {
+ // 解析当前类的字段
+ if (name.equals("isDebuging")) {
+ // 读取 JSON 中 "isDebuging" 字段的值,设置为当前对象的调试状态
+ setIsDebugging(jsonReader.nextBoolean());
+ } else {
+ // 字段不属于当前类,返回 false 提示调用者跳过该字段
+ return false;
+ }
+ }
+ // 字段解析成功,返回 true
+ return true;
+ }
+
+ /**
+ * 重写父类方法,从 JSON 读取器中完整解析一个 APPModel 实例
+ * 负责处理 JSON 对象的开始/结束标记,循环解析所有字段
+ * @param jsonReader JSON 读取器对象,用于读取 JSON 数据
+ * @return 解析完成的当前 APPModel 实例(支持链式调用)
+ * @throws IOException 当 JSON 读取失败时抛出(如流关闭、数据格式错误)
+ */
+ @Override
+ public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException {
+ // 开始解析 JSON 对象(对应 JSON 中的 '{')
+ jsonReader.beginObject();
+ // 循环读取 JSON 中的所有字段(直到对象结束)
+ while (jsonReader.hasNext()) {
+ // 获取当前字段名
+ String name = jsonReader.nextName();
+ // 解析字段:若当前类无法处理该字段,则跳过(避免解析异常)
+ if (!initObjectsFromJsonReader(jsonReader, name)) {
+ jsonReader.skipValue();
+ }
+ }
+ // 结束解析 JSON 对象(对应 JSON 中的 '}')
+ jsonReader.endObject();
+ // 返回解析完成的实例(当前对象)
+ return this;
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/AppCrashSafetyWire.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/AppCrashSafetyWire.java
new file mode 100644
index 0000000..9391189
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/AppCrashSafetyWire.java
@@ -0,0 +1,194 @@
+package cc.winboll.studio.libappbase;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+/**
+ * 应用崩溃保险丝内部类(单例)
+ * 核心作用:限制短时间内重复崩溃,通过「熔断等级」控制崩溃页面启动策略
+ * 等级范围:MINI(1)~ MAX(2),每次崩溃等级-1,熔断后启动基础版崩溃页面
+ */
+public final class AppCrashSafetyWire {
+
+ public static final String TAG = "AppCrashSafetyWire";
+
+ /** 单例实例(volatile 保证多线程可见性) */
+ private static volatile AppCrashSafetyWire _AppCrashSafetyWire;
+
+ /** 当前熔断等级(1:最低防护;2:最高防护;≤0:熔断) */
+ private volatile Integer currentSafetyLevel;
+ /** 最低熔断等级(1,再崩溃则熔断) */
+ private static final int _MINI = 1;
+ /** 最高熔断等级(2,初始状态) */
+ private static final int _MAX = 2;
+
+ /**
+ * 私有构造方法(单例模式,禁止外部实例化)
+ * 初始化时加载本地存储的熔断等级
+ */
+ private AppCrashSafetyWire() {
+ LogUtils.d(TAG, "AppCrashSafetyWire()");
+ currentSafetyLevel = loadCurrentSafetyLevel();
+ }
+
+ /**
+ * 获取单例实例(双重检查锁定,线程安全)
+ * @return AppCrashSafetyWire 单例
+ */
+ public static synchronized AppCrashSafetyWire getInstance() {
+ if (_AppCrashSafetyWire == null) {
+ _AppCrashSafetyWire = new AppCrashSafetyWire();
+ }
+ return _AppCrashSafetyWire;
+ }
+
+ /**
+ * 设置当前熔断等级(内存中)
+ * @param currentSafetyLevel 目标等级(1~2)
+ */
+ public void setCurrentSafetyLevel(int currentSafetyLevel) {
+ this.currentSafetyLevel = currentSafetyLevel;
+ }
+
+ /**
+ * 获取当前熔断等级(内存中)
+ * @return 当前等级(1~2 或 null)
+ */
+ public int getCurrentSafetyLevel() {
+ return currentSafetyLevel;
+ }
+
+ /**
+ * 保存熔断等级到本地文件(持久化,重启应用生效)
+ * @param currentSafetyLevel 待保存的等级
+ */
+ public void saveCurrentSafetyLevel(int currentSafetyLevel) {
+ LogUtils.d(TAG, "saveCurrentSafetyLevel()");
+ this.currentSafetyLevel = currentSafetyLevel;
+ try {
+ // 序列化等级到文件(ObjectOutputStream 写入 int)
+ ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(CrashHandler._CrashCountFilePath));
+ oos.writeInt(currentSafetyLevel);
+ oos.flush();
+ oos.close();
+ LogUtils.d(TAG, String.format("saveCurrentSafetyLevel writeInt currentSafetyLevel %d", currentSafetyLevel));
+ } catch (IOException e) {
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ }
+ }
+
+ /**
+ * 从本地文件加载熔断等级(应用启动时初始化)
+ * @return 加载的等级(文件不存在则初始化为 MAX(2))
+ */
+ public int loadCurrentSafetyLevel() {
+ LogUtils.d(TAG, "loadCurrentSafetyLevel()");
+ try {
+ File f = new File(CrashHandler._CrashCountFilePath);
+ if (f.exists()) {
+ // 反序列化从文件读取等级
+ ObjectInputStream ois = new ObjectInputStream(new FileInputStream(CrashHandler._CrashCountFilePath));
+ currentSafetyLevel = ois.readInt();
+ LogUtils.d(TAG, String.format("loadCurrentSafetyLevel() readInt currentSafetyLevel %d", currentSafetyLevel));
+ } else {
+ // 文件不存在,初始化等级为最高(2)并保存
+ currentSafetyLevel = _MAX;
+ LogUtils.d(TAG, String.format("loadCurrentSafetyLevel() currentSafetyLevel init to _MAX->%d", _MAX));
+ saveCurrentSafetyLevel(currentSafetyLevel);
+ }
+ } catch (IOException e) {
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ }
+ return currentSafetyLevel;
+ }
+
+ /**
+ * 熔断保险丝(每次崩溃调用,降低防护等级)
+ * @return 熔断后是否仍在防护范围内(true:是;false:已熔断)
+ */
+ boolean burnSafetyWire() {
+ LogUtils.d(TAG, "burnSafetyWire()");
+ // 加载当前等级
+ int safeLevel = loadCurrentSafetyLevel();
+ // 若在防护范围内(1~2),等级-1 并保存
+ if (isSafetyWireWorking(safeLevel)) {
+ LogUtils.d(TAG, "burnSafetyWire() use");
+ saveCurrentSafetyLevel(safeLevel - 1);
+ // 返回熔断后的状态
+ return isSafetyWireWorking(safeLevel - 1);
+ }
+ return false;
+ }
+
+ /**
+ * 检查熔断等级是否在有效范围内(1~2)
+ * @param safetyLevel 待检查的等级
+ * @return true:在范围内(防护有效);false:超出范围(已熔断)
+ */
+ boolean isSafetyWireWorking(int safetyLevel) {
+ LogUtils.d(TAG, "isSafetyWireOK()");
+ LogUtils.d(TAG, String.format("SafetyLevel %d", safetyLevel));
+
+ if (safetyLevel >= _MINI && safetyLevel <= _MAX) {
+ LogUtils.d(TAG, String.format("In Safety Level"));
+ return true;
+ }
+ LogUtils.d(TAG, String.format("Out of Safety Level"));
+ return false;
+ }
+
+ /**
+ * 立即恢复熔断等级到最高(2)
+ * 用于重启应用后重置防护状态
+ */
+ void resumeToMaximumImmediately() {
+ LogUtils.d(TAG, "resumeToMaximumImmediately() call saveCurrentSafetyLevel(_MAX)");
+ AppCrashSafetyWire.getInstance().saveCurrentSafetyLevel(_MAX);
+ }
+
+ /**
+ * 关闭防护(设置等级为最低(1))
+ * 下次崩溃直接熔断
+ */
+ void off() {
+ LogUtils.d(TAG, "off()");
+ saveCurrentSafetyLevel(_MINI);
+ }
+
+ /**
+ * 检查当前保险丝是否有效(防护未熔断)
+ * @return true:有效(等级 1~2);false:已熔断
+ */
+ public boolean isAppCrashSafetyWireOK() {
+ LogUtils.d(TAG, "isAppCrashSafetyWireOK()");
+ currentSafetyLevel = loadCurrentSafetyLevel();
+ return isSafetyWireWorking(currentSafetyLevel);
+ }
+
+ /**
+ * 延迟恢复保险丝到最高等级(500ms 后)
+ * 核心作用:崩溃页面启动后,若下次即将熔断,提前恢复防护等级,避免持续崩溃
+ * @param context 上下文(用于获取主线程 Handler)
+ */
+ void postResumeCrashSafetyWireHandler(final Context context) {
+ // 主线程延迟 500ms 执行(避免页面启动时阻塞)
+ new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ LogUtils.d(TAG, "Handler run()");
+ // 检查:若当前等级-1 后超出防护范围(即将熔断),则恢复到最高等级
+ if (!AppCrashSafetyWire.getInstance().isSafetyWireWorking(currentSafetyLevel - 1)) {
+ AppCrashSafetyWire.getInstance().resumeToMaximumImmediately();
+ LogUtils.d(TAG, "postResumeCrashSafetyWireHandler: 恢复保险丝到最高等级");
+ }
+ }
+ }, 500);
+ }
+}
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/BaseBean.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/BaseBean.java
new file mode 100644
index 0000000..372e6ef
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/BaseBean.java
@@ -0,0 +1,436 @@
+package cc.winboll.studio.libappbase;
+
+import android.content.Context;
+import android.util.JsonReader;
+import android.util.JsonWriter;
+import java.io.File;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.ArrayList;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/11 20:03
+ * @Describe WinBoLL JSON 数据模型基类(抽象类)
+ * 定义 Json Bean 的核心规范:序列化/反序列化、文件持久化、列表处理等通用逻辑,
+ * 子类(如 APPModel)需实现抽象方法,实现自身字段的 JSON 读写
+ * @param 泛型约束,限定子类必须继承自 BaseBean
+ */
+public abstract class BaseBean {
+
+ /** 日志标签,用于当前基类的日志输出标识 */
+ public static final String TAG = "BaseBean";
+ /** JSON 中存储 Bean 类名的字段键(用于校验 Bean 类型一致性) */
+ static final String BEAN_NAME = "BeanName";
+
+ /**
+ * 无参构造方法(子类需默认实现,支持反射实例化)
+ */
+ public BaseBean() {}
+
+ /**
+ * 抽象方法:获取当前 Bean 的全限定类名
+ * 子类需实现,用于标识 Bean 类型(序列化/校验时使用)
+ * @return 类的全限定名(如:cc.winboll.studio.libappbase.APPModel)
+ */
+ public abstract String getName();
+
+ /**
+ * 获取单个 Bean 的 JSON 持久化文件路径
+ * 路径:外部存储/应用私有目录/BaseBean/[类名].json
+ * @param context 上下文(用于获取应用存储目录)
+ * @return 单个 Bean 的文件绝对路径
+ */
+ public String getBeanJsonFilePath(Context context) {
+ return context.getExternalFilesDir(TAG) + "/" + getName() + ".json";
+ }
+
+ /**
+ * 获取 Bean 列表的 JSON 持久化文件路径
+ * 路径:外部存储/应用私有目录/BaseBean/[类名]_List.json
+ * @param context 上下文(用于获取应用存储目录)
+ * @return Bean 列表的文件绝对路径
+ */
+ public String getBeanListJsonFilePath(Context context) {
+ return context.getExternalFilesDir(TAG) + "/" + getName() + "_List.json";
+ }
+
+ /**
+ * 将 Bean 类名写入 JSON(序列化基础字段)
+ * 子类可重写扩展,添加自身字段的 JSON 写入逻辑
+ * @param jsonWriter JSON 写入器(用于输出 JSON 数据)
+ * @throws IOException JSON 写入失败时抛出(如流异常)
+ */
+ public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException {
+ // 写入 Bean 类名字段(用于反序列化时校验类型)
+ jsonWriter.name(BEAN_NAME).value(getName());
+ }
+
+ /**
+ * 从 JSON 读取字段并初始化 Bean(反序列化基础逻辑)
+ * 子类需重写,实现自身字段的解析逻辑
+ * @param jsonReader JSON 读取器(用于读取 JSON 数据)
+ * @param name 当前解析的 JSON 字段名
+ * @return true:字段解析成功(当前类处理);false:字段未处理(需跳过)
+ * @throws IOException JSON 读取失败时抛出(如流异常)
+ */
+ public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException {
+ return false; // 基类未处理任何字段,返回 false
+ }
+
+ /**
+ * 抽象方法:从 JSON 读取器解析并返回 Bean 实例
+ * 子类需实现,处理自身字段的完整解析逻辑
+ * @param jsonReader JSON 读取器(用于读取 JSON 数据)
+ * @return 解析完成的 Bean 实例
+ * @throws IOException JSON 读取失败时抛出(如流异常)
+ */
+ abstract public T readBeanFromJsonReader(JsonReader jsonReader) throws IOException;
+
+ /**
+ * 校验 JSON 文件中的 Bean 列表与目标类是否一致
+ * 对比文件中每个 Bean 的类名与目标类名,返回不一致信息
+ * @param szFilePath JSON 文件路径(存储 Bean 列表的文件)
+ * @param clazz 目标 Bean 类(用于校验类型)
+ * @return 空串:校验一致;非空串:不一致信息(总数/差异数)或异常信息
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static String checkIsTheSameBeanListAndFile(String szFilePath, Class clazz) {
+ StringBuilder sbResult = new StringBuilder();
+ String szErrorInfo = "Check Is The Same Bean List And File Error : ";
+
+ try {
+ int sameCount = 0; // 类名匹配的 Bean 数量
+ int totalCount = 0; // 文件中 Bean 总数量
+
+ // 反射创建目标 Bean 实例(用于获取类名)
+ T beanTemp = clazz.newInstance();
+ String targetBeanName = beanTemp.getName();
+ // 读取文件中的 JSON 字符串
+ String listJson = UTF8FileUtils.readStringFromFile(szFilePath);
+ StringReader stringReader = new StringReader(listJson);
+ JsonReader jsonReader = new JsonReader(stringReader);
+
+ jsonReader.beginArray(); // 开始解析 JSON 数组(Bean 列表)
+ while (jsonReader.hasNext()) {
+ totalCount++;
+ jsonReader.beginObject(); // 开始解析单个 Bean 对象
+ while (jsonReader.hasNext()) {
+ String name = jsonReader.nextName();
+ // 只校验 BEAN_NAME 字段,其他字段跳过
+ if (name.equals(BEAN_NAME)) {
+ // 对比当前 Bean 类名与目标类名
+ if (targetBeanName.equals(jsonReader.nextString())) {
+ sameCount++;
+ }
+ } else {
+ jsonReader.skipValue(); // 跳过非目标字段
+ }
+ }
+ jsonReader.endObject(); // 结束单个 Bean 对象解析
+ }
+ jsonReader.endArray(); // 结束 JSON 数组解析
+
+ // 生成校验结果
+ if (sameCount == totalCount) {
+ return ""; // 全部匹配,返回空串
+ } else {
+ // 部分不匹配,返回统计信息
+ sbResult.append("Total : ").append(totalCount)
+ .append(" Diff : ").append(totalCount - sameCount);
+ }
+ } catch (InstantiationException e) {
+ // 反射实例化失败(如无无参构造)
+ sbResult.append(szErrorInfo).append(e);
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ } catch (IllegalAccessException e) {
+ // 反射访问权限异常
+ sbResult.append(szErrorInfo).append(e);
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ } catch (IOException e) {
+ // 文件读取或 JSON 解析异常
+ sbResult.append(szErrorInfo).append(e);
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return sbResult.toString();
+ }
+
+ /**
+ * 将 JSON 字符串解析为目标 Bean 实例
+ * 通过反射创建 Bean 实例,调用子类解析逻辑完成初始化
+ * @param szBean JSON 字符串(单个 Bean 的 JSON 数据)
+ * @param clazz 目标 Bean 类(用于反射实例化)
+ * @return 解析成功的 Bean 实例;失败返回 null
+ * @throws IOException JSON 解析失败时抛出
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static T parseStringToBean(String szBean, Class clazz) throws IOException {
+ StringReader stringReader = new StringReader(szBean);
+ JsonReader jsonReader = new JsonReader(stringReader);
+
+ try {
+ // 反射创建 Bean 实例
+ T beanTemp = clazz.newInstance();
+ // 调用子类解析方法,返回解析后的实例
+ return (T) beanTemp.readBeanFromJsonReader(jsonReader);
+ } catch (InstantiationException | IllegalAccessException e) {
+ // 反射异常日志记录
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return null;
+ }
+
+ /**
+ * 将 JSON 字符串解析为 Bean 列表
+ * 清空目标列表,将解析后的 Bean 逐个添加到列表中
+ * @param szBeanList JSON 字符串(Bean 列表的 JSON 数组)
+ * @param beanList 目标列表(存储解析后的 Bean)
+ * @param clazz 目标 Bean 类(用于反射实例化)
+ * @return true:解析成功;false:解析失败
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static boolean parseStringToBeanList(String szBeanList, ArrayList beanList, Class clazz) {
+ try {
+ // 初始化目标列表(为空则创建,非空则清空)
+ if (beanList == null) {
+ beanList = new ArrayList();
+ } else {
+ beanList.clear();
+ }
+
+ StringReader stringReader = new StringReader(szBeanList);
+ JsonReader jsonReader = new JsonReader(stringReader);
+
+ jsonReader.beginArray(); // 开始解析 JSON 数组
+ while (jsonReader.hasNext()) {
+ // 反射创建 Bean 实例,解析并添加到列表
+ T beanTemp = clazz.newInstance();
+ T bean = (T) beanTemp.readBeanFromJsonReader(jsonReader);
+ if (bean != null) {
+ beanList.add(bean);
+ }
+ }
+ jsonReader.endArray(); // 结束 JSON 数组解析
+ return true;
+ } catch (InstantiationException | IllegalAccessException | IOException e) {
+ // 异常日志记录
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return false;
+ }
+
+ /**
+ * 重写 toString(),将 Bean 序列化为格式化的 JSON 字符串
+ * 调用自身序列化逻辑,生成带缩进的 JSON(便于调试)
+ * @return Bean 的 JSON 字符串;失败返回空串
+ */
+ @Override
+ public String toString() {
+ StringWriter stringWriter = new StringWriter();
+ JsonWriter jsonWriter = new JsonWriter(stringWriter);
+ jsonWriter.setIndent(" "); // 设置 JSON 缩进(格式化输出)
+
+ try {
+ jsonWriter.beginObject(); // 开始 JSON 对象
+ writeThisToJsonWriter(jsonWriter); // 写入 Bean 字段(子类扩展)
+ jsonWriter.endObject(); // 结束 JSON 对象
+ return stringWriter.toString();
+ } catch (IOException e) {
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return "";
+ }
+
+ /**
+ * 将 Bean 列表序列化为格式化的 JSON 字符串
+ * 遍历列表,逐个序列化每个 Bean,生成 JSON 数组
+ * @param beanList 待序列化的 Bean 列表
+ * @return 列表的 JSON 字符串;失败返回空串
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static String toStringByBeanList(ArrayList beanList) {
+ try {
+ StringWriter stringWriter = new StringWriter();
+ JsonWriter jsonWriter = new JsonWriter(stringWriter);
+ jsonWriter.setIndent(" "); // 格式化缩进
+
+ jsonWriter.beginArray(); // 开始 JSON 数组
+ for (int i = 0; i < beanList.size(); i++) {
+ jsonWriter.beginObject(); // 单个 Bean 开始
+ beanList.get(i).writeThisToJsonWriter(jsonWriter); // 调用 Bean 自身序列化
+ jsonWriter.endObject(); // 单个 Bean 结束
+ }
+ jsonWriter.endArray(); // 结束 JSON 数组
+ jsonWriter.close();
+ return stringWriter.toString();
+ } catch (IOException e) {
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return "";
+ }
+
+ /**
+ * 从默认路径(getBeanJsonFilePath)加载 Bean 实例
+ * 读取应用私有目录下的 JSON 文件,解析为目标 Bean
+ * @param context 上下文(用于获取文件路径)
+ * @param clazz 目标 Bean 类(用于反射实例化)
+ * @return 加载成功的 Bean 实例;失败返回 null
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static T loadBean(Context context, Class clazz) {
+ try {
+ // 反射创建 Bean 实例,获取默认文件路径
+ T beanTemp = clazz.newInstance();
+ return loadBeanFromFile(beanTemp.getBeanJsonFilePath(context), clazz);
+ } catch (InstantiationException | IllegalAccessException e) {
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return null;
+ }
+
+ /**
+ * 从指定文件路径加载 Bean 实例
+ * 检查文件是否存在,存在则读取 JSON 并解析为目标 Bean
+ * @param szFilePath 目标文件路径(存储 Bean 的 JSON 文件)
+ * @param clazz 目标 Bean 类(用于反射实例化)
+ * @return 加载成功的 Bean 实例;失败返回 null
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static T loadBeanFromFile(String szFilePath, Class clazz) {
+ try {
+ File file = new File(szFilePath);
+ if (file.exists()) { // 检查文件是否存在
+ T beanTemp = clazz.newInstance();
+ // 读取文件 JSON 字符串,解析为 Bean
+ String json = UTF8FileUtils.readStringFromFile(szFilePath);
+ return beanTemp.parseStringToBean(json, clazz);
+ }
+ } catch (InstantiationException | IllegalAccessException | IOException e) {
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return null;
+ }
+
+ /**
+ * 将 Bean 保存到默认路径(getBeanJsonFilePath)的文件中
+ * 序列化 Bean 为 JSON,写入应用私有目录下的文件
+ * @param context 上下文(用于获取文件路径)
+ * @param bean 待保存的 Bean 实例
+ * @return true:保存成功;false:保存失败
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static boolean saveBean(Context context, T bean) {
+ return saveBeanToFile(bean.getBeanJsonFilePath(context), bean);
+ }
+
+ /**
+ * 将 Bean 保存到指定文件路径
+ * 序列化 Bean 为 JSON 字符串,写入目标文件(覆盖原有内容)
+ * @param szFilePath 目标文件路径(保存 JSON 的文件)
+ * @param bean 待保存的 Bean 实例
+ * @return true:保存成功;false:保存失败
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static boolean saveBeanToFile(String szFilePath, T bean) {
+ try {
+ // 序列化 Bean 为 JSON 字符串
+ String json = bean.toString();
+ // 写入文件(UTF-8 编码)
+ UTF8FileUtils.writeStringToFile(szFilePath, json);
+ return true;
+ } catch (IOException e) {
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return false;
+ }
+
+ /**
+ * 从默认路径(getBeanListJsonFilePath)加载 Bean 列表
+ * 读取应用私有目录下的列表 JSON 文件,解析并填充到目标列表
+ * @param context 上下文(用于获取文件路径)
+ * @param beanListDst 目标列表(存储加载后的 Bean)
+ * @param clazz 目标 Bean 类(用于反射实例化)
+ * @return true:加载成功;false:加载失败
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static boolean loadBeanList(Context context, ArrayList beanListDst, Class clazz) {
+ try {
+ // 反射创建 Bean 实例,获取默认列表文件路径
+ T beanTemp = clazz.newInstance();
+ return loadBeanListFromFile(beanTemp.getBeanListJsonFilePath(context), beanListDst, clazz);
+ } catch (InstantiationException | IllegalAccessException e) {
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return false;
+ }
+
+ /**
+ * 从指定文件路径加载 Bean 列表
+ * 检查文件是否存在,存在则读取 JSON 数组,解析并填充到目标列表
+ * @param szFilePath 目标文件路径(存储列表 JSON 的文件)
+ * @param beanList 目标列表(存储加载后的 Bean)
+ * @param clazz 目标 Bean 类(用于反射实例化)
+ * @return true:加载成功;false:加载失败
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static boolean loadBeanListFromFile(String szFilePath, ArrayList beanList, Class clazz) {
+ try {
+ File file = new File(szFilePath);
+ if (file.exists()) { // 检查文件是否存在
+ // 读取文件中的 JSON 字符串(Bean 列表数组)
+ String listJson = UTF8FileUtils.readStringFromFile(szFilePath);
+ // 解析 JSON 字符串为 Bean 列表,填充到目标列表
+ return parseStringToBeanList(listJson, beanList, clazz);
+ }
+ } catch (IOException e) {
+ // 日志记录文件读取或解析异常
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return false;
+ }
+
+ /**
+ * 将 Bean 列表保存到默认路径(getBeanListJsonFilePath)的文件中
+ * 序列化列表为 JSON 数组,写入应用私有目录下的文件
+ * @param context 上下文(用于获取文件路径)
+ * @param beanList 待保存的 Bean 列表
+ * @param clazz 目标 Bean 类(用于反射获取保存路径)
+ * @return true:保存成功;false:保存失败
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static boolean saveBeanList(Context context, ArrayList beanList, Class clazz) {
+ try {
+ // 反射创建 Bean 实例,获取默认列表保存路径
+ T beanTemp = clazz.newInstance();
+ return saveBeanListToFile(beanTemp.getBeanListJsonFilePath(context), beanList);
+ } catch (InstantiationException | IllegalAccessException e) {
+ // 日志记录反射实例化异常
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return false;
+ }
+
+ /**
+ * 将 Bean 列表保存到指定文件路径
+ * 序列化列表为 JSON 数组字符串,写入目标文件(覆盖原有内容)
+ * @param szFilePath 目标文件路径(保存列表 JSON 的文件)
+ * @param beanList 待保存的 Bean 列表
+ * @return true:保存成功;false:保存失败
+ * @param 泛型约束,限定为 BaseBean 子类
+ */
+ public static boolean saveBeanListToFile(String szFilePath, ArrayList beanList) {
+ try {
+ // 序列化 Bean 列表为 JSON 字符串(数组格式)
+ String json = toStringByBeanList(beanList);
+ // 将 JSON 字符串写入文件(UTF-8 编码)
+ UTF8FileUtils.writeStringToFile(szFilePath, json);
+ return true;
+ } catch (IOException e) {
+ // 日志记录文件写入或序列化异常
+ LogUtils.d(TAG, e.getMessage(), Thread.currentThread().getStackTrace());
+ }
+ return false;
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/CrashHandler.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/CrashHandler.java
new file mode 100644
index 0000000..bfdcf5f
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/CrashHandler.java
@@ -0,0 +1,330 @@
+package cc.winboll.studio.libappbase;
+
+import android.app.Activity;
+import android.app.Application;
+import android.content.ActivityNotFoundException;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.TextUtils;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.ViewGroup;
+import android.widget.HorizontalScrollView;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import cc.winboll.studio.libappbase.utils.CrashHandleNotifyUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.Thread.UncaughtExceptionHandler;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * 应用全局崩溃处理类(单例逻辑)
+ * 核心功能:捕获应用未捕获异常,记录崩溃日志到文件,启动崩溃报告页面,
+ * 并通过「崩溃保险丝」机制防止重复崩溃,保障基础功能可用
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2025/11/11 20:14:00
+ * @EditTime 2026/05/11 15:36:45
+ */
+public final class CrashHandler {
+
+ // ====================== 常量定义 ======================
+ /** 日志标签 */
+ public static final String TAG = "CrashHandler";
+ /** 崩溃报告页面标题 */
+ public static final String TITTLE = "CrashReport";
+ /** Intent 传递崩溃信息键 */
+ public static final String EXTRA_CRASH_LOG = "crashInfo";
+ /** SharedPreferences 存储键 */
+ static final String PREFS = CrashHandler.class.getName() + "PREFS";
+ /** 标记是否发生崩溃键 */
+ static final String PREFS_CRASHHANDLER_ISCRASHHAPPEN = "PREFS_CRASHHANDLER_ISCRASHHAPPEN";
+
+ // ====================== 成员变量 ======================
+ /** 崩溃保险丝状态文件路径 */
+ public static String _CrashCountFilePath;
+ /** 系统默认异常处理器兜底 */
+ public static final UncaughtExceptionHandler DEFAULT_UNCAUGHT_EXCEPTION_HANDLER
+ = Thread.getDefaultUncaughtExceptionHandler();
+
+ // ====================== 对外初始化方法 ======================
+ /**
+ * 初始化崩溃处理器(默认存储路径)
+ * @param app 全局Application实例
+ */
+ public static void init(final Application app) {
+ _CrashCountFilePath = app.getExternalFilesDir("CrashHandler") + "/IsCrashHandlerCrashHappen.dat";
+ LogUtils.d(TAG, "init _CrashCountFilePath = " + _CrashCountFilePath);
+ init(app, null);
+ }
+
+ /**
+ * 初始化崩溃处理器(自定义日志目录)
+ * @param app 全局Application实例
+ * @param crashDir 自定义崩溃日志目录,传null使用默认
+ */
+ public static void init(final Application app, final String crashDir) {
+ Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler() {
+ @Override
+ public void uncaughtException(final Thread thread, final Throwable throwable) {
+ try {
+ tryUncaughtException(thread, throwable, crashDir, app);
+ } catch (Throwable e) {
+ LogUtils.e(TAG, "uncaughtException error", e);
+ if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) {
+ DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(thread, throwable);
+ }
+ }
+ }
+ });
+ }
+
+ // ====================== 内部崩溃处理核心 ======================
+ /**
+ * 执行崩溃信息收集、日志写入、跳转崩溃页面
+ */
+ private static void tryUncaughtException(final Thread thread,
+ final Throwable throwable,
+ final String crashDir,
+ final Application app) {
+ // 触发崩溃保险丝
+ AppCrashSafetyWire.getInstance().burnSafetyWire();
+
+ // 格式化时间
+ final String time = new SimpleDateFormat("yyyy_MM_dd-HH_mm_ss",
+ Locale.getDefault()).format(new Date());
+
+ // 创建日志文件
+ File logParent = TextUtils.isEmpty(crashDir)
+ ? new File(app.getExternalFilesDir(null), "crash")
+ : new File(crashDir);
+ final File crashFile = new File(logParent, "crash_" + time + ".txt");
+
+ // 获取应用版本信息
+ String versionName = "unknown";
+ long versionCode = 0;
+ try {
+ final PackageInfo packageInfo = app.getPackageManager()
+ .getPackageInfo(app.getPackageName(), 0);
+ versionName = packageInfo.versionName;
+ if (Build.VERSION.SDK_INT >= 28) {
+ versionCode = packageInfo.getLongVersionCode();
+ } else {
+ versionCode = packageInfo.versionCode;
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.e(TAG, "get package info fail");
+ }
+
+ // 抓取异常堆栈
+ String fullStackTrace;
+ StringWriter sw = new StringWriter();
+ PrintWriter pw = new PrintWriter(sw);
+ throwable.printStackTrace(pw);
+ fullStackTrace = sw.toString();
+ pw.close();
+
+ // 拼接崩溃头部信息
+ StringBuilder sb = new StringBuilder();
+ sb.append("************* Crash Head ****************\n");
+ sb.append("Time Of Crash : ").append(time).append("\n");
+ sb.append("Device Manufacturer : ").append(Build.MANUFACTURER).append("\n");
+ sb.append("Device Model : ").append(Build.MODEL).append("\n");
+ sb.append("Android Version : ").append(Build.VERSION.RELEASE).append("\n");
+ sb.append("Android SDK : ").append(Build.VERSION.SDK_INT).append("\n");
+ sb.append("App VersionName : ").append(versionName).append("\n");
+ sb.append("App VersionCode : ").append(versionCode).append("\n");
+ sb.append("************* Crash Head ****************\n");
+ sb.append("\n").append(fullStackTrace);
+
+ final String errorLog = sb.toString();
+
+ // 写入日志文件
+ try {
+ writeFile(crashFile, errorLog);
+ } catch (IOException e) {
+ LogUtils.e(TAG, "write crash log file fail");
+ }
+
+ // 跳转崩溃页面
+ gotoCrashActivity(errorLog, app);
+ }
+
+ /**
+ * 写入文本到文件
+ */
+ private static void writeFile(final File file, final String content) throws IOException {
+ final File parentFile = file.getParentFile();
+ if (parentFile != null && !parentFile.exists()) {
+ parentFile.mkdirs();
+ }
+ file.createNewFile();
+ FileOutputStream fos = new FileOutputStream(file);
+ fos.write(content.getBytes());
+ fos.close();
+ }
+
+ /**
+ * 根据保险丝状态跳转对应崩溃页面
+ */
+ private static void gotoCrashActivity(final String errorLog, final Application app) {
+ final Intent intent = new Intent();
+ if (AppCrashSafetyWire.getInstance().isAppCrashSafetyWireOK()) {
+ intent.setClass(app, GlobalCrashActivity.class);
+ } else {
+ intent.setClass(app, CrashActivity.class);
+ }
+ intent.putExtra(EXTRA_CRASH_LOG, errorLog);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+
+ try {
+ if (GlobalApplication.isDebugging()) {
+ app.startActivity(intent);
+ } else {
+ CrashHandleNotifyUtils.handleUncaughtException(app, intent, GlobalCrashActivity.class);
+ }
+ android.os.Process.killProcess(android.os.Process.myPid());
+ System.exit(0);
+ } catch (ActivityNotFoundException e) {
+ LogUtils.e(TAG, "CrashActivity not found");
+ if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) {
+ DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(Thread.currentThread(), e);
+ }
+ } catch (Exception e) {
+ LogUtils.e(TAG, "start CrashActivity error");
+ if (DEFAULT_UNCAUGHT_EXCEPTION_HANDLER != null) {
+ DEFAULT_UNCAUGHT_EXCEPTION_HANDLER.uncaughtException(Thread.currentThread(), e);
+ }
+ }
+ }
+
+ // ====================== 内部Activity页面 ======================
+ /**
+ * 基础极简崩溃页面
+ * 保险丝熔断时启动,避免复杂布局二次崩溃
+ */
+ public static final class CrashActivity extends Activity implements MenuItem.OnMenuItemClickListener {
+ private static final int MENUITEM_COPY = 0;
+ private static final int MENUITEM_RESTART = 1;
+
+ private String mLog;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ AppCrashSafetyWire.getInstance().postResumeCrashSafetyWireHandler(getApplicationContext());
+ mLog = getIntent().getStringExtra(EXTRA_CRASH_LOG);
+ setTheme(android.R.style.Theme_DeviceDefault_Light_DarkActionBar);
+ initLayout();
+ }
+
+ /**
+ * 动态初始化布局
+ */
+ private void initLayout() {
+ ScrollView contentView = new ScrollView(this);
+ contentView.setFillViewport(true);
+
+ HorizontalScrollView hw = new HorizontalScrollView(this);
+ hw.setBackgroundColor(0xFFF5F5F5);
+
+ TextView message = new TextView(this);
+ final int padding = dp2px(16);
+ message.setPadding(padding, padding, padding, padding);
+ message.setText(mLog);
+ message.setTextColor(0xFF000000);
+ message.setTextIsSelectable(true);
+
+ hw.addView(message);
+ contentView.addView(hw, ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT);
+ setContentView(contentView);
+
+ getActionBar().setTitle(TITTLE);
+ getActionBar().setSubtitle(GlobalApplication.class.getSimpleName() + " Error");
+ }
+
+ @Override
+ public void onBackPressed() {
+ restartApp();
+ }
+
+ /**
+ * 重启应用
+ */
+ private void restartApp() {
+ final Intent intent = getPackageManager()
+ .getLaunchIntentForPackage(getPackageName());
+ if (intent != null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ startActivity(intent);
+ }
+ finish();
+ android.os.Process.killProcess(android.os.Process.myPid());
+ System.exit(0);
+ }
+
+ /**
+ * dp转px
+ */
+ private int dp2px(final float dpValue) {
+ final float scale = Resources.getSystem().getDisplayMetrics().density;
+ return (int) (dpValue * scale + 0.5f);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ menu.add(0, MENUITEM_COPY, 0, "Copy")
+ .setOnMenuItemClickListener(this)
+ .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+
+ menu.add(0, MENUITEM_RESTART, 0, "Restart")
+ .setOnMenuItemClickListener(this)
+ .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+ return true;
+ }
+
+ @Override
+ public boolean onMenuItemClick(final MenuItem item) {
+ switch (item.getItemId()) {
+ case MENUITEM_COPY:
+ ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
+ cm.setPrimaryClip(ClipData.newPlainText(getPackageName(), mLog));
+ Toast.makeText(getApplication(), "The text is copied.", Toast.LENGTH_SHORT).show();
+ break;
+ case MENUITEM_RESTART:
+ AppCrashSafetyWire.getInstance().resumeToMaximumImmediately();
+ restartApp();
+ break;
+ default:
+ break;
+ }
+ return false;
+ }
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalApplication.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalApplication.java
new file mode 100644
index 0000000..378ac59
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalApplication.java
@@ -0,0 +1,224 @@
+package cc.winboll.studio.libappbase;
+
+import android.app.Application;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/11 19:56
+ * @Describe 全局 Application 类,用于初始化应用核心组件、管理全局状态(如调试模式)
+ * 需在 AndroidManifest.xml 中配置 android:name=".GlobalApplication" 使其生效
+ */
+public class GlobalApplication extends Application {
+
+ /** 日志标签 */
+ public static final String TAG = "GlobalApplication";
+
+ /** 全局 Application 单例实例(volatile 保证多线程可见性,避免指令重排) */
+ private static volatile GlobalApplication sInstance;
+
+ /**
+ * 应用调试模式标记(volatile 保证多线程可见性)
+ * true:调试模式(开启日志、调试功能);false:正式模式(关闭调试相关功能)
+ */
+ private static volatile boolean isDebugging = false;
+
+ // 新增:WinBoLL 服务器主机地址(volatile 保证多线程可见性)
+ private static volatile String winbollHost = null;
+ // 新增:SP 存储相关常量(私有存储,仅当前应用可访问)
+ private static final String SP_NAME = "WinBoLL_SP_CONFIG";
+ private static final String SP_KEY_WINBOLL_HOST = "winboll_host";
+
+ /**
+ * 获取全局 Application 单例实例(外部可通过此方法获取上下文)
+ * @return GlobalApplication 单例(未初始化时返回 null,需确保配置 AndroidManifest)
+ */
+ public static GlobalApplication getInstance() {
+ return sInstance;
+ }
+
+ /**
+ * 设置应用调试模式
+ * @param debugging 调试模式状态(true/false)
+ */
+ public static void setIsDebugging(boolean debugging) {
+ isDebugging = debugging;
+ }
+
+ /**
+ * 保存调试模式状态到本地文件(持久化存储,重启应用后生效)
+ * @param application 全局 Application 实例(通过 getInstance() 获取更规范)
+ */
+ public static void saveDebugStatus(GlobalApplication application) {
+ if (application == null) {
+ LogUtils.e(TAG, "saveDebugStatus: Application 实例为空,保存失败");
+ return;
+ }
+ // 将调试状态封装为 APPModel 并保存到文件
+ APPModel.saveBeanToFile(
+ getAppModelFilePath(application),
+ new APPModel(isDebugging)
+ );
+ }
+
+ /**
+ * 获取 APPModel 配置文件的存储路径
+ * 路径:应用私有数据目录 / APPModel.json(仅当前应用可访问,安全)
+ * @param application 全局 Application 实例
+ * @return 配置文件绝对路径
+ */
+ private static String getAppModelFilePath(GlobalApplication application) {
+ return application.getDataDir().getPath() + "/APPModel.json";
+ }
+
+ /**
+ * 获取当前应用调试模式状态
+ * @return true:调试模式;false:正式模式
+ */
+ public static boolean isDebugging() {
+ return isDebugging;
+ }
+
+ // 新增:设置 WinBoLL 服务器主机地址(同时保存到 SP 持久化)
+ public static void setWinbollHost(String host) {
+ if (sInstance == null) {
+ LogUtils.e(TAG, "setWinbollHost: 应用未初始化,设置失败");
+ return;
+ }
+ // 检查并补全末尾 / 核心改动
+ if (host != null && !host.isEmpty() && !host.endsWith("/")) {
+ host += "/";
+ }
+ // 更新内存中的字段
+ winbollHost = host;
+ // 保存到 SP 持久化(私有模式,安全)
+ SharedPreferences sp = sInstance.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
+ sp.edit().putString(SP_KEY_WINBOLL_HOST, host).apply();
+ LogUtils.d(TAG, "setWinbollHost: 服务器地址已设置并持久化,host=" + host);
+ }
+
+
+ // 新增:获取 WinBoLL 服务器主机地址(优先内存,内存为空则从 SP 读取)
+ public static String getWinbollHost() {
+ if (winbollHost != null) {
+ // 内存中存在,直接返回(提高效率)
+ return winbollHost;
+ }
+ if (sInstance == null) {
+ LogUtils.e(TAG, "getWinbollHost: 应用未初始化,获取失败");
+ return null;
+ }
+ // 内存中不存在,从 SP 读取并更新到内存
+ SharedPreferences sp = sInstance.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
+ winbollHost = sp.getString(SP_KEY_WINBOLL_HOST, "https://console.winboll.cc/");
+ LogUtils.d(TAG, "getWinbollHost: 从 SP 读取服务器地址,host=" + winbollHost);
+ return winbollHost;
+ }
+
+ /**
+ * 应用启动时初始化(仅执行一次)
+ * 初始化核心框架、恢复调试状态、配置全局异常处理等
+ */
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ // 初始化单例实例(确保在所有初始化操作前完成)
+ sInstance = this;
+
+ // 初始化基础组件(日志、崩溃处理、Toast)
+ initCoreComponents();
+ // 恢复/初始化调试模式状态(从本地文件读取,无文件则默认关闭调试)
+ restoreDebugStatus();
+ // 新增:初始化服务器地址(从 SP 读取到内存,提高后续访问效率)
+ initWinbollHost();
+
+ LogUtils.d(TAG, "GlobalApplication 初始化完成,单例实例已创建");
+ }
+
+ /**
+ * 初始化应用核心组件(日志、崩溃处理、Toast 框架)
+ */
+ private void initCoreComponents() {
+ // 初始化日志工具(传入 Application 上下文)
+ LogUtils.init(this);
+ // 初始化全局异常处理器(捕获应用崩溃信息,用于调试或上报)
+ CrashHandler.init(this);
+ // 初始化 Toast 工具(统一 Toast 样式、避免内存泄漏等)
+ ToastUtils.init(this);
+ }
+
+ /**
+ * 恢复调试模式状态(从本地配置文件读取)
+ * 1. 读取本地 APPModel.json 文件
+ * 2. 读取成功:使用保存的调试状态;读取失败(文件不存在):默认关闭调试并创建配置文件
+ */
+ private void restoreDebugStatus() {
+ // 从文件加载 APPModel 实例(存储调试状态的模型类)
+ APPModel appModel = APPModel.loadBeanFromFile(
+ getAppModelFilePath(this),
+ APPModel.class
+ );
+
+ if (appModel == null) {
+ // 配置文件不存在,默认关闭调试模式并创建文件
+ setIsDebugging(false);
+ saveDebugStatus(this);
+ LogUtils.d(TAG, "调试配置文件不存在,默认关闭调试模式并创建配置文件");
+ } else {
+ // 配置文件存在,使用保存的调试状态
+ setIsDebugging(appModel.isDebugging());
+ LogUtils.d(TAG, "从配置文件恢复调试模式:" + isDebugging);
+ }
+ }
+
+ // 新增:初始化服务器地址(应用启动时从 SP 读取到内存)
+ private void initWinbollHost() {
+ getWinbollHost(); // 触发从 SP 读取并更新内存
+ }
+
+ /**
+ * 获取应用名称(从 AndroidManifest.xml 的 android:label 读取)
+ * @param context 上下文(建议传入 Application 上下文,避免内存泄漏)
+ * @return 应用名称(读取失败返回 null)
+ */
+ public static String getAppName(Context context) {
+ if (context == null) {
+ LogUtils.w(TAG, "getAppName: 上下文为空,返回 null");
+ return null;
+ }
+ PackageManager packageManager = context.getPackageManager();
+ try {
+ // 获取应用信息(包含应用名称、图标等)
+ ApplicationInfo applicationInfo = packageManager.getApplicationInfo(
+ context.getPackageName(), // 当前应用包名
+ 0 // 额外标志(0 表示默认获取基本信息)
+ );
+ // 从应用信息中获取应用名称(支持多语言)
+ String appName = (String) packageManager.getApplicationLabel(applicationInfo);
+ LogUtils.d(TAG, "获取应用名称成功:" + appName);
+ return appName;
+ } catch (NameNotFoundException e) {
+ // 包名不存在(理论上不会发生,捕获异常避免崩溃)
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ //LogUtils.e(TAG, "获取应用名称失败:包名不存在", e);
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ /**
+ * 应用终止时调用(仅用于释放全局资源)
+ */
+ @Override
+ public void onTerminate() {
+ super.onTerminate();
+ // 释放单例引用(可选,避免内存泄漏风险)
+ sInstance = null;
+ LogUtils.d(TAG, "GlobalApplication 终止,单例实例已释放");
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashActivity.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashActivity.java
new file mode 100644
index 0000000..1b185fd
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashActivity.java
@@ -0,0 +1,151 @@
+package cc.winboll.studio.libappbase;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import cc.winboll.studio.libappbase.utils.CrashHandleNotifyUtils;
+
+/**
+ * 应用异常报告观察活动窗口类
+ * 核心功能:应用发生未捕获崩溃时,由 CrashHandler 启动此页面,展示崩溃日志详情,
+ * 并提供「复制日志」「重启应用」操作入口,便于开发者定位问题和用户恢复应用
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2025/11/11 19:58:00
+ * @EditTime 2026/05/11 15:40:12
+ */
+public final class GlobalCrashActivity extends Activity implements MenuItem.OnMenuItemClickListener {
+
+ // ====================== 常量定义 ======================
+ public static final String TAG = "GlobalCrashActivity";
+ /** 菜单标识:复制崩溃日志 */
+ private static final int MENU_ITEM_COPY = 0;
+ /** 菜单标识:重启应用 */
+ private static final int MENU_ITEM_RESTART = 1;
+
+ // ====================== 成员变量 ======================
+ /** 崩溃报告展示自定义视图 */
+ private GlobalCrashReportView mCrashReportView;
+ /** 崩溃日志文本内容 */
+ private String mCrashLog;
+
+ // ====================== 生命周期方法 ======================
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ LogUtils.d(TAG, "onCreate 方法进入");
+ try {
+ super.onCreate(savedInstanceState);
+ final Context appContext = getApplicationContext();
+ // 初始化崩溃安全防护机制
+ AppCrashSafetyWire.getInstance().postResumeCrashSafetyWireHandler(appContext);
+
+ // 获取传递的崩溃日志
+ mCrashLog = getIntent().getStringExtra(CrashHandler.EXTRA_CRASH_LOG);
+ LogUtils.d(TAG, "获取到崩溃日志,长度:" + (mCrashLog != null ? mCrashLog.length() : 0));
+
+ setContentView(R.layout.activity_globalcrash);
+ mCrashReportView = findViewById(R.id.activityglobalcrashGlobalCrashReportView1);
+ mCrashReportView.setReport(mCrashLog);
+
+ setActionBar(mCrashReportView.getToolbar());
+ if (getActionBar() != null) {
+ getActionBar().setTitle(CrashHandler.TITTLE);
+ getActionBar().setSubtitle(GlobalApplication.getAppName(appContext));
+ }
+ } catch (final Exception e) {
+ LogUtils.e(TAG, "GlobalCrashActivity onCreate 发生异常", e);
+ AppCrashSafetyWire.getInstance().burnSafetyWire();
+
+ mCrashLog = getIntent().getStringExtra(CrashHandler.EXTRA_CRASH_LOG);
+ final Intent intent = new Intent();
+ intent.putExtra(CrashHandler.EXTRA_CRASH_LOG, mCrashLog);
+ CrashHandleNotifyUtils.handleUncaughtException(GlobalApplication.getInstance(), intent, CrashHandler.CrashActivity.class);
+
+ StackTraceElement[] stackElements = Thread.currentThread().getStackTrace();
+ StringBuilder sb = new StringBuilder("GlobalCrashActivity onCreate StackTrace");
+ for (StackTraceElement item : stackElements) {
+ sb.append("\n").append(item.toString());
+ }
+ LogUtils.d(TAG, sb.toString());
+ finish();
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ LogUtils.d(TAG, "onBackPressed 触发重启应用");
+ restartApp();
+ }
+
+ // ====================== 菜单相关回调 ======================
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ LogUtils.d(TAG, "onCreateOptionsView 初始化菜单");
+ menu.add(0, MENU_ITEM_COPY, 0, "Copy")
+ .setOnMenuItemClickListener(this)
+ .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+
+ menu.add(0, MENU_ITEM_RESTART, 0, "Restart")
+ .setOnMenuItemClickListener(this)
+ .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+
+ mCrashReportView.updateMenuStyle();
+ return true;
+ }
+
+ @Override
+ public boolean onMenuItemClick(final MenuItem item) {
+ LogUtils.d(TAG, "菜单项被点击,ID:" + item.getItemId());
+ switch (item.getItemId()) {
+ case MENU_ITEM_COPY:
+ copyCrashLogToClipboard();
+ break;
+ case MENU_ITEM_RESTART:
+ AppCrashSafetyWire.getInstance().resumeToMaximumImmediately();
+ restartApp();
+ break;
+ default:
+ break;
+ }
+ return false;
+ }
+
+ // ====================== 内部私有工具方法 ======================
+ /**
+ * 重启当前应用
+ */
+ private void restartApp() {
+ LogUtils.d(TAG, "开始执行应用重启逻辑");
+ final PackageManager packageManager = getPackageManager();
+ final Intent launchIntent = packageManager.getLaunchIntentForPackage(getPackageName());
+
+ if (launchIntent != null) {
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ startActivity(launchIntent);
+ }
+ finish();
+ android.os.Process.killProcess(android.os.Process.myPid());
+ System.exit(0);
+ }
+
+ /**
+ * 将崩溃日志复制到系统剪贴板
+ */
+ private void copyCrashLogToClipboard() {
+ LogUtils.d(TAG, "执行复制崩溃日志到剪贴板");
+ final ClipboardManager clipboardManager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
+ final ClipData clipData = ClipData.newPlainText(getPackageName(), mCrashLog);
+ clipboardManager.setPrimaryClip(clipData);
+ Toast.makeText(getApplication(), "The text is copied.", Toast.LENGTH_SHORT).show();
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashReportView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashReportView.java
new file mode 100644
index 0000000..7ecedcc
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/GlobalCrashReportView.java
@@ -0,0 +1,317 @@
+package cc.winboll.studio.libappbase;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.text.SpannableString;
+import android.text.style.ForegroundColorSpan;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toolbar;
+import cc.winboll.studio.libappbase.R;
+import android.content.res.Resources;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/11 20:21
+ * @Describe 全局崩溃报告视图控件
+ * 用于展示应用崩溃信息,包含顶部工具栏和崩溃日志文本区域,支持自定义配色
+ */
+public class GlobalCrashReportView extends LinearLayout {
+
+ // 日志标签
+ public static final String TAG = "GlobalCrashReportView";
+
+ // 上下文对象
+ private Context mContext;
+ // 顶部工具栏(标题栏)
+ private Toolbar mToolbar;
+ // 标题文字颜色
+ private int mTitleColor;
+ // 标题栏背景颜色
+ private int mTitleBackgroundColor;
+ // 日志文本颜色
+ private int mTextColor;
+ // 日志区域背景颜色
+ private int mTextBackgroundColor;
+ // 崩溃日志显示文本控件
+ private TextView mTvReport;
+
+ /**
+ * 构造方法:仅上下文
+ * @param context 上下文
+ */
+ public GlobalCrashReportView(Context context) {
+ super(context);
+ mContext = context;
+ // 初始化默认配置(无自定义属性)
+ initDefaultConfig();
+ }
+
+ /**
+ * 构造方法:上下文 + 自定义属性
+ * @param context 上下文
+ * @param attrs 自定义属性集合
+ */
+ public GlobalCrashReportView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ // 初始化视图(解析自定义属性)
+ initView(attrs);
+ }
+
+ /**
+ * 构造方法:上下文 + 自定义属性 + 样式属性
+ * @param context 上下文
+ * @param attrs 自定义属性集合
+ * @param defStyleAttr 样式属性
+ */
+ public GlobalCrashReportView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ mContext = context;
+ // 初始化视图(解析自定义属性)
+ initView(attrs);
+ }
+
+ /**
+ * 构造方法:上下文 + 自定义属性 + 样式属性 + 样式资源
+ * @param context 上下文
+ * @param attrs 自定义属性集合
+ * @param defStyleAttr 样式属性
+ * @param defStyleRes 样式资源
+ */
+ public GlobalCrashReportView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ mContext = context;
+ // 初始化视图(解析自定义属性)
+ initView(attrs);
+ }
+
+ /**
+ * 设置标题文字颜色
+ * @param titleColor 颜色值(如 Color.WHITE 或 #FFFFFF)
+ */
+ public void setTitleColor(int titleColor) {
+ this.mTitleColor = titleColor;
+ // 实时更新工具栏标题颜色
+ if (mToolbar != null) {
+ mToolbar.setTitleTextColor(titleColor);
+ mToolbar.setSubtitleTextColor(titleColor);
+ }
+ }
+
+ /**
+ * 获取标题文字颜色
+ * @return 标题文字颜色值
+ */
+ public int getTitleColor() {
+ return mTitleColor;
+ }
+
+ /**
+ * 设置标题栏背景颜色
+ * @param titleBackgroundColor 颜色值(如 Color.BLACK 或 #000000)
+ */
+ public void setTitleBackgroundColor(int titleBackgroundColor) {
+ this.mTitleBackgroundColor = titleBackgroundColor;
+ // 实时更新工具栏背景颜色
+ if (mToolbar != null) {
+ mToolbar.setBackgroundColor(titleBackgroundColor);
+ }
+ }
+
+ /**
+ * 获取标题栏背景颜色
+ * @return 标题栏背景颜色值
+ */
+ public int getTitleBackgroundColor() {
+ return mTitleBackgroundColor;
+ }
+
+ /**
+ * 设置日志文本颜色
+ * @param textColor 颜色值(如 Color.BLACK 或 #000000)
+ */
+ public void setTextColor(int textColor) {
+ this.mTextColor = textColor;
+ // 实时更新日志文本颜色
+ if (mTvReport != null) {
+ mTvReport.setTextColor(textColor);
+ }
+ }
+
+ /**
+ * 获取日志文本颜色
+ * @return 日志文本颜色值
+ */
+ public int getTextColor() {
+ return mTextColor;
+ }
+
+ /**
+ * 设置日志区域背景颜色
+ * @param textBackgroundColor 颜色值(如 Color.WHITE 或 #FFFFFF)
+ */
+ public void setTextBackgroundColor(int textBackgroundColor) {
+ this.mTextBackgroundColor = textBackgroundColor;
+ // 实时更新日志区域和主布局背景颜色
+ if (mTvReport != null) {
+ mTvReport.setBackgroundColor(textBackgroundColor);
+ }
+ setBackgroundColor(textBackgroundColor);
+ }
+
+ /**
+ * 获取日志区域背景颜色
+ * @return 日志区域背景颜色值
+ */
+ public int getTextBackgroundColor() {
+ return mTextBackgroundColor;
+ }
+
+ /**
+ * 初始化默认配置(无自定义属性时使用)
+ */
+ private void initDefaultConfig() {
+ // 设置默认配色(使用 debugTextColor 属性)
+ Resources.Theme theme = mContext.getTheme();
+ mTitleColor = theme.getResources().getColor(android.R.color.holo_green_dark);
+ mTitleBackgroundColor = Color.GRAY;
+ mTextColor = obtainDebugTextColor(theme);
+ mTextBackgroundColor = Color.WHITE;
+ // 加载布局
+ inflateView();
+ // 初始化控件样式
+ initWidgetStyle();
+ }
+
+ private int obtainDebugTextColor(Resources.Theme theme) {
+ int[] attrs = new int[] { cc.winboll.studio.libappbase.R.attr.themeDebug };
+ TypedArray themeTypedArray = theme.obtainStyledAttributes(attrs);
+ int themeResId = themeTypedArray.getResourceId(0, 0);
+ themeTypedArray.recycle();
+ if (themeResId != 0) {
+ int[] debugAttrs = new int[] { cc.winboll.studio.libappbase.R.attr.debugTextColor };
+ TypedArray debugTypedArray = theme.obtainStyledAttributes(themeResId, debugAttrs);
+ int color = debugTypedArray.getColor(0, Color.GRAY);
+ debugTypedArray.recycle();
+ return color;
+ }
+ return Color.GRAY;
+ }
+
+ /**
+ * 初始化视图(解析自定义属性 + 加载布局 + 设置样式)
+ * @param attrs 自定义属性集合
+ */
+ private void initView(AttributeSet attrs) {
+ // 解析自定义属性(关联 attrs.xml 中的 GlobalCrashActivity 样式)
+ TypedArray typedArray = mContext.obtainStyledAttributes(
+ attrs,
+ R.styleable.GlobalCrashActivity,
+ R.attr.themeDebug,
+ 0
+ );
+
+ // 读取自定义属性值(无设置时使用默认值)
+ mTitleColor = typedArray.getColor(
+ R.styleable.GlobalCrashActivity_colorTittle,
+ Color.BLACK
+ );
+ mTitleBackgroundColor = typedArray.getColor(
+ R.styleable.GlobalCrashActivity_colorTittleBackgound, // 注:原拼写错误(Backgound→Background),保持与 attrs.xml 一致
+ Color.BLACK
+ );
+ mTextColor = obtainDebugTextColor(mContext.getTheme());
+ mTextBackgroundColor = typedArray.getColor(
+ R.styleable.GlobalCrashActivity_colorTextBackgound, // 注:原拼写错误,保持与 attrs.xml 一致
+ Color.WHITE
+ );
+
+ // 回收 TypedArray,避免内存泄漏
+ typedArray.recycle();
+
+ // 加载布局文件
+ inflateView();
+ // 初始化控件样式
+ initWidgetStyle();
+ }
+
+ /**
+ * 加载布局文件
+ */
+ private void inflateView() {
+ // 加载自定义布局(R.layout.view_globalcrashreport)
+ inflate(mContext, R.layout.view_globalcrashreport, this);
+ // 绑定控件
+ mToolbar = findViewById(R.id.viewglobalcrashreportToolbar1);
+ mTvReport = findViewById(R.id.viewglobalcrashreportTextView1);
+ }
+
+ /**
+ * 初始化控件样式(设置配色和基础属性)
+ */
+ private void initWidgetStyle() {
+ // 配置工具栏样式
+ if (mToolbar != null) {
+ mToolbar.setTitleTextColor(mTitleColor);
+ mToolbar.setSubtitleTextColor(mTitleColor);
+ }
+
+ // 配置日志文本控件样式
+ if (mTvReport != null) {
+ mTvReport.setTextColor(mTextColor);
+ mTvReport.setSingleLine(false);
+ mTvReport.setHorizontallyScrolling(false);
+ }
+ }
+
+ /**
+ * 设置崩溃报告内容到文本控件
+ * @param report 崩溃日志字符串(通常包含异常信息、调用栈等)
+ */
+ public void setReport(String report) {
+ if (mTvReport != null) {
+ mTvReport.setText(report);
+ }
+ }
+
+ /**
+ * 获取顶部工具栏对象(用于外部设置标题、添加菜单等)
+ * @return Toolbar 实例
+ */
+ public Toolbar getToolbar() {
+ return mToolbar;
+ }
+
+ /**
+ * 更新工具栏菜单文字颜色(与标题颜色保持一致)
+ * 需在菜单加载完成后调用(如 Toolbar 加载菜单后)
+ */
+ public void updateMenuStyle() {
+ if (mToolbar == null) return;
+
+ // 获取工具栏菜单
+ Menu menu = mToolbar.getMenu();
+ if (menu == null || menu.size() == 0) return;
+
+ // 遍历所有菜单项,设置文字颜色
+ for (int i = 0; i < menu.size(); i++) {
+ MenuItem menuItem = menu.getItem(i);
+ String title = menuItem.getTitle().toString();
+ // 使用 SpannableString 设置文字颜色
+ SpannableString spanString = new SpannableString(title);
+ spanString.setSpan(
+ new ForegroundColorSpan(mTitleColor),
+ 0,
+ spanString.length(),
+ 0 // Spannable.SPAN_INCLUSIVE_EXCLUSIVE(默认值,包含起始位置,不包含结束位置)
+ );
+ menuItem.setTitle(spanString);
+ }
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogActivity.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogActivity.java
new file mode 100644
index 0000000..8c0e5af
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogActivity.java
@@ -0,0 +1,87 @@
+package cc.winboll.studio.libappbase;
+
+import android.app.Activity;
+import android.app.ActivityOptions;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Bundle;
+import cc.winboll.studio.libappbase.LogView;
+import cc.winboll.studio.libappbase.R;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/11 20:29
+ * @Describe 应用日志展示 Activity
+ * 用于单独启动窗口展示应用运行日志,依赖 LogView 控件实现日志加载与显示
+ */
+public class LogActivity extends Activity {
+
+ /** 日志标签,用于当前 Activity 的日志输出标识 */
+ public static final String TAG = "LogActivity";
+
+ /** 日志展示控件(用于加载和显示应用日志) */
+ private LogView mLogView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ // 设置布局文件(包含 LogView 控件)
+ setContentView(R.layout.activity_log);
+
+ // 绑定布局中的 LogView 控件
+ mLogView = findViewById(R.id.logview);
+ // 启动 LogView 日志加载(如实时刷新日志内容)
+ mLogView.start();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ // 恢复 Activity 时重新启动 LogView(确保日志持续更新)
+ mLogView.start();
+ }
+
+ /**
+ * 启动日志 Activity 的静态方法(外部调用入口)
+ * 配置 Intent 标志,以多任务/分屏模式启动,避免与主应用任务栈冲突
+ * @param context 上下文(Activity/Fragment),用于启动 Activity
+ */
+ public static void startLogActivity(Context context) {
+ startLogActivity(context, true);
+ }
+
+ /**
+ * 启动日志 Activity 的静态方法重载(外部调用入口)
+ * @param context 上下文(Activity/Fragment),用于启动 Activity
+ * @param newTask 是否在新窗口中启动
+ */
+ public static void startLogActivity(Context context, boolean newTask) {
+ Intent intent = new Intent(context, LogActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ if (newTask) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
+ context.startActivity(intent);
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
+
+ Rect bounds = new Rect();
+ if (context instanceof Activity) {
+ Activity activity = (Activity) context;
+ activity.getWindow().getDecorView().getDisplay().getRectSize(bounds);
+ bounds.set(0, bounds.height() / 2, bounds.width(), bounds.height());
+ }
+ ActivityOptions options = ActivityOptions.makeBasic();
+ options.setLaunchBounds(bounds);
+ context.startActivity(intent, options.toBundle());
+ } else {
+ context.startActivity(intent);
+ }
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtils.java
new file mode 100644
index 0000000..8ac2fe9
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtils.java
@@ -0,0 +1,683 @@
+package cc.winboll.studio.libappbase;
+
+import android.content.Context;
+import android.util.Log;
+import android.widget.Toast;
+
+import cc.winboll.studio.libappbase.GlobalApplication;
+import dalvik.system.DexFile;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2026-05-09 15:46:28
+ * @EditTime 2026-05-11 15:36:12
+ * @Describe 应用日志工具类
+ * 补全多级别日志重载、自动日志文件裁剪、应用内TAG自动扫描管理;
+ * 支持日志本地持久化、异常堆栈格式化、TAG开关配置、线程与集合打印工具;
+ * 完全兼容Java7语法,严格遵循变量final编码规范;
+ * 重要说明:本类内部调试打印必须使用 android.util.Log,禁止使用LogUtils自身方法,
+ * 避免递归嵌套调用、逻辑漩涡与无限循环调用问题。
+ */
+public class LogUtils {
+
+ // ====================== 常量与枚举 ======================
+ public static final String TAG = "LogUtils";
+
+ /**
+ * 日志级别枚举
+ */
+ public static enum LOG_LEVEL {
+ Off,
+ Error,
+ Warn,
+ Info,
+ Debug,
+ Verbose
+ }
+
+ // ====================== 全局静态成员 ======================
+ private static volatile boolean _IsInited = false;
+ private static Context _mContext;
+ private static final SimpleDateFormat mSimpleDateFormat = new SimpleDateFormat("[yyyyMMdd_HHmmss_SSS]", Locale.getDefault());
+ private static File _mfLogCacheDir;
+ private static File _mfLogDataDir;
+ private static File _mfLogCatchFile;
+ private static File _mfLogUtilsBeanFile;
+ private static LogUtilsBean _mLogUtilsBean;
+ public static final Map mapTAGList = new HashMap();
+
+ // ====================== 初始化入口 ======================
+ public static void init(final Context context) {
+ init(context, LOG_LEVEL.Off);
+ }
+
+ public static void init(final Context context, final LOG_LEVEL logLevel) {
+ Log.d(TAG, "init 执行日志工具初始化");
+ _mContext = context;
+
+ if (GlobalApplication.isDebugging()) {
+ initDebugDir();
+ } else {
+ initReleaseDir();
+ }
+
+ initLogConfigBean();
+ addClassTAGList();
+ loadTAGBeanSettings();
+ checkAndTrimLogFileSize();
+
+ _IsInited = true;
+ Log.d(TAG, "init 日志工具初始化完成");
+ }
+
+ // ====================== 目录初始化 ======================
+ private static void initDebugDir() {
+ final Context appContext = _mContext.getApplicationContext();
+ _mfLogCacheDir = new File(appContext.getExternalCacheDir(), TAG);
+ if (!_mfLogCacheDir.exists()) {
+ _mfLogCacheDir.mkdirs();
+ }
+ _mfLogCatchFile = new File(_mfLogCacheDir, "log.txt");
+
+ _mfLogDataDir = appContext.getExternalFilesDir(TAG);
+ if (!_mfLogDataDir.exists()) {
+ _mfLogDataDir.mkdirs();
+ }
+ _mfLogUtilsBeanFile = new File(_mfLogDataDir, TAG + ".json");
+ }
+
+ private static void initReleaseDir() {
+ final Context appContext = _mContext.getApplicationContext();
+ _mfLogCacheDir = new File(appContext.getCacheDir(), TAG);
+ if (!_mfLogCacheDir.exists()) {
+ _mfLogCacheDir.mkdirs();
+ }
+ _mfLogCatchFile = new File(_mfLogCacheDir, "log.txt");
+
+ _mfLogDataDir = new File(appContext.getFilesDir(), TAG);
+ if (!_mfLogDataDir.exists()) {
+ _mfLogDataDir.mkdirs();
+ }
+ _mfLogUtilsBeanFile = new File(_mfLogDataDir, TAG + ".json");
+ }
+
+ private static void initLogConfigBean() {
+ _mLogUtilsBean = LogUtilsBean.loadBeanFromFile(_mfLogUtilsBeanFile.getPath(), LogUtilsBean.class);
+ if (_mLogUtilsBean == null) {
+ _mLogUtilsBean = new LogUtilsBean();
+ _mLogUtilsBean.saveBeanToFile(_mfLogUtilsBeanFile.getPath(), _mLogUtilsBean);
+ Log.d(TAG, "initLogConfigBean 自动创建默认日志配置文件");
+ }
+ }
+
+ // ====================== 日志文件裁剪 ======================
+ private static void checkAndTrimLogFileSize() {
+ if (_mfLogCatchFile == null || !_mfLogCatchFile.exists()) {
+ return;
+ }
+
+ final long MAX_FILE_SIZE = 6291456L;
+ final long KEEP_FILE_SIZE = 3145728L;
+ final long fileSize = _mfLogCatchFile.length();
+
+ if (fileSize <= MAX_FILE_SIZE) {
+ return;
+ }
+
+ final long needSkip = fileSize - KEEP_FILE_SIZE;
+ final String lineBreak = System.lineSeparator();
+
+ FileInputStream fis = null;
+ BufferedReader reader = null;
+ FileOutputStream fos = null;
+ BufferedWriter writer = null;
+
+ try {
+ fis = new FileInputStream(_mfLogCatchFile);
+ reader = new BufferedReader(new InputStreamReader(fis));
+ fos = new FileOutputStream(_mfLogCatchFile);
+ writer = new BufferedWriter(new OutputStreamWriter(fos));
+
+ final StringBuilder sb = new StringBuilder();
+ String line;
+ long skippedTotal = 0;
+
+ while ((line = reader.readLine()) != null) {
+ final byte[] lineBytes = line.getBytes();
+ skippedTotal += lineBytes.length + lineBreak.getBytes().length;
+ if (skippedTotal > needSkip) {
+ sb.append(line).append(lineBreak);
+ }
+ }
+ writer.write(sb.toString());
+ Log.d(TAG, "checkAndTrimLogFileSize 日志文件裁剪完成");
+ } catch (IOException e) {
+ Log.e(TAG, "checkAndTrimLogFileSize 日志文件裁剪失败", e);
+ } finally {
+ try {
+ if (reader != null) {
+ reader.close();
+ }
+ } catch (IOException e) {}
+ try {
+ if (fis != null) {
+ fis.close();
+ }
+ } catch (IOException e) {}
+ try {
+ if (writer != null) {
+ writer.close();
+ }
+ } catch (IOException e) {}
+ try {
+ if (fos != null) {
+ fos.close();
+ }
+ } catch (IOException e) {}
+ }
+ }
+
+ // ====================== TAG 配置管理 ======================
+ public static Map getMapTAGList() {
+ return mapTAGList;
+ }
+
+ private static void loadTAGBeanSettings() {
+ final ArrayList list = new ArrayList();
+ LogUtilsClassTAGBean.loadBeanList(_mContext, list, LogUtilsClassTAGBean.class);
+
+ for (final LogUtilsClassTAGBean beanSetting : list) {
+ final String tag = beanSetting.getTag();
+ if (mapTAGList.containsKey(tag)) {
+ mapTAGList.put(tag, beanSetting.getEnable());
+ }
+ }
+ }
+
+ private static void saveTAGBeanSettings() {
+ final ArrayList list = new ArrayList();
+ for (final Map.Entry entry : mapTAGList.entrySet()) {
+ list.add(new LogUtilsClassTAGBean(entry.getKey(), entry.getValue()));
+ }
+ LogUtilsClassTAGBean.saveBeanList(_mContext, list, LogUtilsClassTAGBean.class);
+ }
+
+ private static void addClassTAGList() {
+ try {
+ final String packageNamePrefix = "cc.winboll.studio";
+ final List classNames = new ArrayList();
+ final String apkPath = _mContext.getPackageCodePath();
+
+ final DexFile dexfile = new DexFile(apkPath);
+ Enumeration entries = dexfile.entries();
+
+ while (entries.hasMoreElements()) {
+ final String className = entries.nextElement();
+ if (className.startsWith(packageNamePrefix)) {
+ classNames.add(className);
+ }
+ }
+
+ for (final String className : classNames) {
+ try {
+ final Class> clazz = Class.forName(className);
+ final Field[] fields = clazz.getDeclaredFields();
+
+ for (final Field field : fields) {
+ if (Modifier.isStatic(field.getModifiers())
+ && Modifier.isPublic(field.getModifiers())
+ && field.getType() == String.class
+ && "TAG".equals(field.getName())) {
+
+ final String tagValue = (String) field.get(null);
+ mapTAGList.put(tagValue, false);
+ }
+ }
+ } catch (NoClassDefFoundError e) {
+ Log.w(TAG, "addClassTAGList 解析类TAG失败 NoClassDefFoundError");
+ } catch (ClassNotFoundException e) {
+ Log.w(TAG, "addClassTAGList 解析类TAG失败 ClassNotFoundException");
+ } catch (IllegalAccessException e) {
+ Log.w(TAG, "addClassTAGList 解析类TAG失败 IllegalAccessException");
+ }
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "addClassTAGList 扫描Dex异常", e);
+ }
+ }
+
+ public static void setTAGListEnable(final String tag, final boolean isEnable) {
+ final Iterator> iterator = mapTAGList.entrySet().iterator();
+ while (iterator.hasNext()) {
+ final Map.Entry entry = iterator.next();
+ if (tag.equals(entry.getKey())) {
+ entry.setValue(isEnable);
+ break;
+ }
+ }
+ saveTAGBeanSettings();
+ Log.d(TAG, "setTAGListEnable 更新TAG开关配置");
+ }
+
+ public static void setALlTAGListEnable(final boolean isEnable) {
+ for (final Map.Entry entry : mapTAGList.entrySet()) {
+ entry.setValue(isEnable);
+ }
+ saveTAGBeanSettings();
+ Log.d(TAG, "setALlTAGListEnable 全量设置TAG开关");
+ }
+
+ // ====================== 日志级别控制 ======================
+ public static void setLogLevel(final LOG_LEVEL logLevel) {
+ _mLogUtilsBean.setLogLevel(logLevel);
+ _mLogUtilsBean.saveBeanToFile(_mfLogUtilsBeanFile.getPath(), _mLogUtilsBean);
+ }
+
+ public static LOG_LEVEL getLogLevel() {
+ return _mLogUtilsBean.getLogLevel();
+ }
+
+ private static boolean isLoggable(final String tag, final LOG_LEVEL logLevel) {
+ if (!_IsInited) {
+ return false;
+ }
+ if (mapTAGList.get(tag) == null || !mapTAGList.get(tag)) {
+ return false;
+ }
+ return isInTheLevel(logLevel);
+ }
+
+ private static boolean isInTheLevel(final LOG_LEVEL logLevel) {
+ return _mLogUtilsBean.getLogLevel().ordinal() >= logLevel.ordinal();
+ }
+
+ // ====================== 基础对外方法 ======================
+ public static File getLogCacheDir() {
+ return _mfLogCacheDir;
+ }
+
+ public static boolean isInited() {
+ return _IsInited;
+ }
+
+ // ====================== Error 日志重载(补齐缺失方法) ======================
+ public static void e(final String szTAG, final String szMessage) {
+ if (isLoggable(szTAG, LOG_LEVEL.Error)) {
+ saveLog(szTAG, LOG_LEVEL.Error, szMessage);
+ }
+ }
+
+ public static void e(final String szTAG, final String szMessage, final Exception e) {
+ if (isLoggable(szTAG, LOG_LEVEL.Error)) {
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【异常信息】: ").append(getExceptionInfo(e));
+ saveLog(szTAG, LOG_LEVEL.Error, sb.toString());
+ }
+ }
+
+ public static void e(final String szTAG, final Exception e) {
+ if (isLoggable(szTAG, LOG_LEVEL.Error)) {
+ final String message = "【异常信息】: " + getExceptionInfo(e);
+ saveLog(szTAG, LOG_LEVEL.Error, message);
+ }
+ }
+
+ public static void e(final String szTAG, final String szMessage, final Exception e, final StackTraceElement[] listStackTrace) {
+ if (isLoggable(szTAG, LOG_LEVEL.Error)) {
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【异常信息】: ").append(getExceptionInfo(e));
+ sb.append("\n【堆栈信息】: ").append(getStackTraceInfo(listStackTrace));
+ saveLog(szTAG, LOG_LEVEL.Error, sb.toString());
+ }
+ }
+
+ /**
+ * 补齐你需要的:LogUtils.e(TAG, "uncaughtException: 崩溃处理异常", e)
+ */
+ public static void e(final String szTAG, final String szMessage, final Throwable e) {
+ if (isLoggable(szTAG, LOG_LEVEL.Error)) {
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【异常信息】: ").append(getThrowableInfo(e));
+ saveLog(szTAG, LOG_LEVEL.Error, sb.toString());
+ }
+ }
+
+ // ====================== Warn 日志重载 ======================
+ public static void w(final String szTAG, final String szMessage) {
+ if (isLoggable(szTAG, LOG_LEVEL.Warn)) {
+ saveLog(szTAG, LOG_LEVEL.Warn, szMessage);
+ }
+ }
+
+ public static void w(final String szTAG, final String szMessage, final Exception e) {
+ if (isLoggable(szTAG, LOG_LEVEL.Warn)) {
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【异常信息】: ").append(getExceptionInfo(e));
+ saveLog(szTAG, LOG_LEVEL.Warn, sb.toString());
+ }
+ }
+
+ public static void w(final String szTAG, final Exception e) {
+ if (isLoggable(szTAG, LOG_LEVEL.Warn)) {
+ final String message = "【异常信息】: " + getExceptionInfo(e);
+ saveLog(szTAG, LOG_LEVEL.Warn, message);
+ }
+ }
+
+ // ====================== Info 日志重载 ======================
+ public static void i(final String szTAG, final String szMessage) {
+ if (isLoggable(szTAG, LOG_LEVEL.Info)) {
+ saveLog(szTAG, LOG_LEVEL.Info, szMessage);
+ }
+ }
+
+ public static void i(final String szTAG, final String szMessage, final Object obj) {
+ if (isLoggable(szTAG, LOG_LEVEL.Info)) {
+ final String objStr = obj == null ? "null" : obj.toString();
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【数据对象】: ").append(objStr);
+ saveLog(szTAG, LOG_LEVEL.Info, sb.toString());
+ }
+ }
+
+ // ====================== Debug 日志重载 ======================
+ public static void d(final String szTAG, final String szMessage) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug)) {
+ saveLog(szTAG, LOG_LEVEL.Debug, szMessage);
+ }
+ }
+
+ public static void d(final String szTAG, final String szMessage, final StackTraceElement[] listStackTrace) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug)) {
+ final StringBuilder sbMessage = new StringBuilder(szMessage);
+ sbMessage.append("\n【调用堆栈】: ").append(getStackTraceInfo(listStackTrace));
+ saveLog(szTAG, LOG_LEVEL.Debug, sbMessage.toString());
+ }
+ }
+
+ public static void d(final String szTAG, final String szMessage, final Exception e) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug)) {
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【异常信息】: ").append(getExceptionInfo(e));
+ saveLog(szTAG, LOG_LEVEL.Debug, sb.toString());
+ }
+ }
+
+ public static void d(final String szTAG, final Exception e, final StackTraceElement[] listStackTrace) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug)) {
+ final StringBuilder sbMessage = new StringBuilder();
+ sbMessage.append("【异常信息】: ").append(getExceptionInfo(e));
+ sbMessage.append("\n【调用堆栈】: ").append(getStackTraceInfo(listStackTrace));
+ saveLog(szTAG, LOG_LEVEL.Debug, sbMessage.toString());
+ }
+ }
+
+ public static void d(final String szTAG, final String szMessage, final Object obj) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug)) {
+ final String objStr = obj == null ? "null" : obj.toString();
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【数据对象】: ").append(objStr);
+ saveLog(szTAG, LOG_LEVEL.Debug, sb.toString());
+ }
+ }
+
+ public static void d(final String szTAG, final Object obj) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug)) {
+ final String objStr = obj == null ? "null" : obj.toString();
+ final String message = "【数据对象】: " + objStr;
+ saveLog(szTAG, LOG_LEVEL.Debug, message);
+ }
+ }
+
+ // ====================== Verbose 日志重载 ======================
+ public static void v(final String szTAG, final String szMessage) {
+ if (isLoggable(szTAG, LOG_LEVEL.Verbose)) {
+ saveLog(szTAG, LOG_LEVEL.Verbose, szMessage);
+ }
+ }
+
+ public static void v(final String szTAG, final String szMessage, final Object obj) {
+ if (isLoggable(szTAG, LOG_LEVEL.Verbose)) {
+ final String objStr = obj == null ? "null" : obj.toString();
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【数据对象】: ").append(objStr);
+ saveLog(szTAG, LOG_LEVEL.Verbose, sb.toString());
+ }
+ }
+
+ public static void v(final String szTAG, final Object obj) {
+ if (isLoggable(szTAG, LOG_LEVEL.Verbose)) {
+ final String objStr = obj == null ? "null" : obj.toString();
+ final String message = "【数据对象】: " + objStr;
+ saveLog(szTAG, LOG_LEVEL.Verbose, message);
+ }
+ }
+
+ // ====================== 扩展工具打印 ======================
+ public static void printThreadInfo(final String szTAG, final String szMessage) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug)) {
+ final Thread currentThread = Thread.currentThread();
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【线程信息】: ")
+ .append("线程名=").append(currentThread.getName())
+ .append(", 线程ID=").append(currentThread.getId())
+ .append(", 线程状态=").append(currentThread.getState().name());
+ saveLog(szTAG, LOG_LEVEL.Debug, sb.toString());
+ }
+ }
+
+ public static void printMap(final String szTAG, final String szMessage, final Map map) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug) && map != null) {
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【Map 数据】size=").append(map.size());
+ for (final Map.Entry entry : map.entrySet()) {
+ final String keyStr = entry.getKey() == null ? "null" : entry.getKey().toString();
+ final String valueStr = entry.getValue() == null ? "null" : entry.getValue().toString();
+ sb.append("\n ").append(keyStr).append(" = ").append(valueStr);
+ }
+ saveLog(szTAG, LOG_LEVEL.Debug, sb.toString());
+ }
+ }
+
+ public static void printList(final String szTAG, final String szMessage, final List list) {
+ if (isLoggable(szTAG, LOG_LEVEL.Debug) && list != null) {
+ final StringBuilder sb = new StringBuilder(szMessage);
+ sb.append("\n【List 数据】size=").append(list.size());
+ for (int i = 0; i < list.size(); i++) {
+ final T item = list.get(i);
+ final String itemStr = item == null ? "null" : item.toString();
+ sb.append("\n 索引").append(i).append(" = ").append(itemStr);
+ }
+ saveLog(szTAG, LOG_LEVEL.Debug, sb.toString());
+ }
+ }
+
+ // ====================== 私有格式化工具 ======================
+ private static String getExceptionInfo(final Exception e) {
+ if (e == null) {
+ return "异常对象为null";
+ }
+ final StringBuilder sb = new StringBuilder();
+ sb.append(e.getClass().getSimpleName()).append(" : ")
+ .append(e.getMessage() == null ? "无异常消息" : e.getMessage());
+
+ final StackTraceElement[] stackTrace = e.getStackTrace();
+ if (stackTrace != null && stackTrace.length > 0) {
+ final int limit = Math.min(stackTrace.length, 5);
+ for (int i = 0; i < limit; i++) {
+ sb.append("\n ").append(stackTrace[i].toString());
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String getThrowableInfo(final Throwable e) {
+ if (e == null) {
+ return "异常对象为null";
+ }
+ final StringBuilder sb = new StringBuilder();
+ sb.append(e.getClass().getSimpleName()).append(" : ")
+ .append(e.getMessage() == null ? "无异常消息" : e.getMessage());
+
+ final StackTraceElement[] stackTrace = e.getStackTrace();
+ if (stackTrace != null && stackTrace.length > 0) {
+ final int limit = Math.min(stackTrace.length, 5);
+ for (int i = 0; i < limit; i++) {
+ sb.append("\n ").append(stackTrace[i].toString());
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String getStackTraceInfo(final StackTraceElement[] stackTrace) {
+ if (stackTrace == null || stackTrace.length == 0) {
+ return "堆栈信息为空";
+ }
+ final StringBuilder sb = new StringBuilder();
+ int count = 0;
+ for (final StackTraceElement element : stackTrace) {
+ if (element.getClassName().contains("cc.winboll.studio.libappbase.LogUtils")) {
+ continue;
+ }
+ sb.append("\n ").append(element.getClassName()).append(".")
+ .append(element.getMethodName()).append("(")
+ .append(element.getFileName()).append(":").append(element.getLineNumber()).append(")");
+ count++;
+ if (count >= 8) {
+ break;
+ }
+ }
+ return sb.toString();
+ }
+
+ // ====================== 日志持久化读写 ======================
+ private static void saveLog(final String szTAG, final LogUtils.LOG_LEVEL logLevel, final String szMessage) {
+ BufferedWriter out = null;
+ try {
+ if (!_mfLogCatchFile.exists()) {
+ final File parentDir = _mfLogCatchFile.getParentFile();
+ if (parentDir != null && !parentDir.exists()) {
+ parentDir.mkdirs();
+ }
+ _mfLogCatchFile.createNewFile();
+ }
+ out = new BufferedWriter(new OutputStreamWriter(
+ new FileOutputStream(_mfLogCatchFile, true), "UTF-8"));
+ final String logLine = "[" + logLevel + "] "
+ + mSimpleDateFormat.format(System.currentTimeMillis())
+ + " [" + szTAG + "]\n"
+ + szMessage + "\n\n";
+ out.write(logLine);
+ out.flush();
+ } catch (IOException e) {
+ Log.e(TAG, "saveLog 日志写入失败", e);
+ } finally {
+ if (out != null) {
+ try {
+ out.close();
+ } catch (IOException e) {
+ Log.e(TAG, "saveLog 流关闭失败", e);
+ }
+ }
+ }
+ }
+
+ public static String loadLog() {
+ if (_mfLogCatchFile == null || !_mfLogCatchFile.exists()) {
+ return "日志文件不存在";
+ }
+ final StringBuffer sb = new StringBuffer();
+ BufferedReader in = null;
+ try {
+ in = new BufferedReader(new InputStreamReader(
+ new FileInputStream(_mfLogCatchFile), "UTF-8"));
+ String line;
+ while ((line = in.readLine()) != null) {
+ sb.append(line).append("\n");
+ }
+ } catch (IOException e) {
+ sb.append("日志加载失败");
+ Log.e(TAG, "loadLog 读取日志异常", e);
+ } finally {
+ if (in != null) {
+ try {
+ in.close();
+ } catch (IOException e) {
+ Log.e(TAG, "loadLog 读取流关闭异常", e);
+ }
+ }
+ }
+ return sb.toString();
+ }
+
+ public static void cleanLog() {
+ if (_mfLogCatchFile == null) {
+ Log.d(TAG, "cleanLog 日志文件未初始化");
+ return;
+ }
+ BufferedWriter out = null;
+ try {
+ out = new BufferedWriter(new OutputStreamWriter(
+ new FileOutputStream(_mfLogCatchFile, false), "UTF-8"));
+ out.write("");
+ out.flush();
+ Log.d(TAG, "cleanLog 日志已清空");
+ } catch (IOException e) {
+ Log.e(TAG, "cleanLog 清空日志失败", e);
+ } finally {
+ if (out != null) {
+ try {
+ out.close();
+ } catch (IOException e) {
+ Log.e(TAG, "cleanLog 流关闭异常", e);
+ }
+ }
+ }
+ }
+
+ public static boolean checkLogFileSize(final int maxSizeMB) {
+ if (_mfLogCatchFile == null || !_mfLogCatchFile.exists()) {
+ return false;
+ }
+ final long maxSizeByte = 1048576L * maxSizeMB;
+ final long fileSize = _mfLogCatchFile.length();
+ return fileSize > maxSizeByte;
+ }
+
+ public static void showShortToast(final Context context, final String message) {
+ if (context == null || message == null) {
+ return;
+ }
+ if (Thread.currentThread().getId() == android.os.Process.myTid()) {
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
+ } else {
+ final Context uiContext = context;
+ ((android.app.Activity) uiContext).runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(uiContext, message, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtilsBean.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtilsBean.java
new file mode 100644
index 0000000..b8556b9
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtilsBean.java
@@ -0,0 +1,131 @@
+package cc.winboll.studio.libappbase;
+
+import android.util.JsonReader;
+import android.util.JsonWriter;
+import java.io.IOException;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/08/23 15:39:07
+ * @Describe LogUtils 配置数据模型(继承 BaseBean,实现 JSON 序列化/反序列化)
+ * 封装 LogUtils 的核心配置参数(当前仅日志级别),用于配置的持久化存储与读取
+ */
+public class LogUtilsBean extends BaseBean {
+
+ /** 当前类的日志 TAG(用于调试输出) */
+ public static final String TAG = "LogUtilsBean";
+
+ /**
+ * 全局日志级别(默认值:Off,即不输出任何日志)
+ * 关联 LogUtils.LOG_LEVEL 枚举,存储日志输出的级别阈值
+ */
+ private LogUtils.LOG_LEVEL logLevel;
+
+ /**
+ * 无参构造方法(默认初始化日志级别为 Off)
+ * 用于 JSON 反序列化时的实例创建
+ */
+ public LogUtilsBean() {
+ this.logLevel = LogUtils.LOG_LEVEL.Off;
+ }
+
+ /**
+ * 有参构造方法(指定初始日志级别)
+ * @param logLevel 初始日志级别(如 LogUtils.LOG_LEVEL.Debug)
+ */
+ public LogUtilsBean(LogUtils.LOG_LEVEL logLevel) {
+ this.logLevel = logLevel;
+ }
+
+ /**
+ * 设置日志级别(更新配置时使用)
+ * @param logLevel 目标日志级别
+ */
+ public void setLogLevel(LogUtils.LOG_LEVEL logLevel) {
+ this.logLevel = logLevel;
+ }
+
+ /**
+ * 获取当前日志级别(读取配置时使用)
+ * @return 当前配置的日志级别
+ */
+ public LogUtils.LOG_LEVEL getLogLevel() {
+ return logLevel;
+ }
+
+ /**
+ * 重写父类方法:获取当前类的全限定名(用于 BaseBean 反射识别)
+ * @return 类全限定名(如 "cc.winboll.studio.libappbase.LogUtilsBean")
+ */
+ @Override
+ public String getName() {
+ return LogUtilsBean.class.getName();
+ }
+
+ /**
+ * 重写父类方法:将当前配置对象序列化为 JSON(持久化存储时调用)
+ * 序列化字段:logLevel(存储枚举的 ordinal 值,确保反序列化一致性)
+ * @param jsonWriter JSON 写入器(用于输出 JSON 数据)
+ * @throws IOException JSON 写入异常(如流关闭、格式错误)
+ */
+ @Override
+ public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException {
+ // 调用父类序列化逻辑(若 BaseBean 有公共字段,需优先处理)
+ super.writeThisToJsonWriter(jsonWriter);
+ // 序列化日志级别:存储枚举的索引值(如 Off=0、Error=1...),比存储名称更高效
+ jsonWriter.name("logLevel").value(this.getLogLevel().ordinal());
+ }
+
+ /**
+ * 重写父类方法:从 JSON 字段初始化当前对象(读取配置时调用)
+ * 解析字段:logLevel(通过索引值恢复 LogUtils.LOG_LEVEL 枚举)
+ * @param jsonReader JSON 读取器(用于读取 JSON 数据)
+ * @param name JSON 字段名(当前解析的字段)
+ * @return true:字段解析成功;false:字段不匹配(需父类处理或跳过)
+ * @throws IOException JSON 读取异常(如字段类型不匹配、流中断)
+ */
+ @Override
+ public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException {
+ // 先让父类处理公共字段,处理成功则直接返回
+ if (super.initObjectsFromJsonReader(jsonReader, name)) {
+ return true;
+ }
+ // 解析当前类专属字段
+ if ("logLevel".equals(name)) {
+ // 通过枚举索引值恢复枚举实例(确保与序列化时的 ordinal 对应)
+ int levelOrdinal = jsonReader.nextInt();
+ this.setLogLevel(LogUtils.LOG_LEVEL.values()[levelOrdinal]);
+ } else {
+ // 字段不匹配,返回 false 表示需要跳过该字段
+ return false;
+ }
+ // 字段解析成功
+ return true;
+ }
+
+ /**
+ * 重写父类方法:从 JSON 读取器完整解析配置对象(入口方法)
+ * 负责 JSON 对象的开始/结束解析,遍历所有字段并调用 initObjectsFromJsonReader 处理
+ * @param jsonReader JSON 读取器(传入待解析的 JSON 流)
+ * @return 解析后的当前 LogUtilsBean 实例(支持链式调用)
+ * @throws IOException JSON 解析异常(如格式错误、字段缺失)
+ */
+ @Override
+ public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException {
+ // 开始解析 JSON 对象(必须与 writeThisToJsonWriter 中的结构对应)
+ jsonReader.beginObject();
+ // 遍历 JSON 中的所有字段
+ while (jsonReader.hasNext()) {
+ String fieldName = jsonReader.nextName();
+ // 解析字段,若字段不匹配则跳过该值(避免解析失败)
+ if (!this.initObjectsFromJsonReader(jsonReader, fieldName)) {
+ jsonReader.skipValue();
+ }
+ }
+ // 结束 JSON 对象解析(必须调用,否则会导致流异常)
+ jsonReader.endObject();
+ // 返回当前实例,支持链式调用(如 new LogUtilsBean().readBeanFromJsonReader(reader))
+ return this;
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtilsClassTAGBean.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtilsClassTAGBean.java
new file mode 100644
index 0000000..83ce79e
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogUtilsClassTAGBean.java
@@ -0,0 +1,161 @@
+package cc.winboll.studio.libappbase;
+
+import android.util.JsonReader;
+import android.util.JsonWriter;
+import java.io.IOException;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2025/01/04 14:17:02
+ * @Describe 日志 TAG 过滤配置模型(继承 BaseBean,实现 JSON 序列化/反序列化)
+ * 封装单个日志 TAG 的名称及其启用状态,用于 LogUtils 的 TAG 过滤规则持久化存储与读取
+ */
+public class LogUtilsClassTAGBean extends BaseBean {
+
+ /** 当前类的日志 TAG(用于调试输出) */
+ public static final String TAG = "LogUtilsClassTAGBean";
+
+ /**
+ * 日志 TAG 名称(如 "LogViewThread"、"ToastUtils")
+ * 与 LogUtils 中扫描的应用内 TAG 一一对应
+ */
+ private String tag;
+
+ /**
+ * TAG 启用状态(控制该 TAG 的日志是否输出)
+ * true:启用(输出该 TAG 的日志);false:禁用(不输出该 TAG 的日志)
+ */
+ private Boolean enable;
+
+ /**
+ * 无参构造方法(默认初始化:TAG 为当前类 TAG,启用状态为 true)
+ * 用于 JSON 反序列化时的实例创建,或默认配置生成
+ */
+ public LogUtilsClassTAGBean() {
+ this.tag = TAG; // 默认 TAG 为当前类的 TAG
+ this.enable = true; // 默认启用该 TAG 的日志输出
+ }
+
+ /**
+ * 有参构造方法(指定 TAG 名称和启用状态)
+ * 用于主动创建 TAG 过滤配置实例
+ * @param tag 日志 TAG 名称
+ * @param enable TAG 启用状态(true/false)
+ */
+ public LogUtilsClassTAGBean(String tag, Boolean enable) {
+ this.tag = tag;
+ this.enable = enable;
+ }
+
+ /**
+ * 设置日志 TAG 名称
+ * @param tag 目标 TAG 名称
+ */
+ public void setTag(String tag) {
+ this.tag = tag;
+ }
+
+ /**
+ * 获取日志 TAG 名称
+ * @return 当前配置的 TAG 名称
+ */
+ public String getTag() {
+ return tag;
+ }
+
+ /**
+ * 设置 TAG 启用状态
+ * @param enable 目标启用状态(true:启用;false:禁用)
+ */
+ public void setEnable(Boolean enable) {
+ this.enable = enable;
+ }
+
+ /**
+ * 获取 TAG 启用状态
+ * @return 当前 TAG 的启用状态
+ */
+ public Boolean getEnable() {
+ return enable;
+ }
+
+ /**
+ * 重写父类方法:获取当前类的全限定名(用于 BaseBean 反射识别)
+ * @return 类全限定名(如 "cc.winboll.studio.libappbase.LogUtilsClassTAGBean")
+ */
+ @Override
+ public String getName() {
+ return LogUtilsClassTAGBean.class.getName();
+ }
+
+ /**
+ * 重写父类方法:将当前 TAG 配置对象序列化为 JSON(持久化存储时调用)
+ * 序列化字段:tag(TAG 名称)、enable(启用状态)
+ * @param jsonWriter JSON 写入器(用于输出 JSON 数据)
+ * @throws IOException JSON 写入异常(如流关闭、格式错误)
+ */
+ @Override
+ public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException {
+ // 调用父类序列化逻辑(若 BaseBean 有公共字段,需优先处理)
+ super.writeThisToJsonWriter(jsonWriter);
+ // 序列化 TAG 名称
+ jsonWriter.name("tag").value(this.getTag());
+ // 序列化启用状态
+ jsonWriter.name("enable").value(this.getEnable());
+ }
+
+ /**
+ * 重写父类方法:从 JSON 字段初始化当前对象(读取配置时调用)
+ * 解析字段:tag(TAG 名称)、enable(启用状态)
+ * @param jsonReader JSON 读取器(用于读取 JSON 数据)
+ * @param name JSON 字段名(当前解析的字段)
+ * @return true:字段解析成功;false:字段不匹配(需父类处理或跳过)
+ * @throws IOException JSON 读取异常(如字段类型不匹配、流中断)
+ */
+ @Override
+ public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException {
+ // 先让父类处理公共字段,处理成功则直接返回
+ if (super.initObjectsFromJsonReader(jsonReader, name)) {
+ return true;
+ }
+ // 解析当前类专属字段
+ if ("tag".equals(name)) {
+ // 读取 TAG 名称并设置
+ this.setTag(jsonReader.nextString());
+ } else if ("enable".equals(name)) {
+ // 读取启用状态并设置
+ this.setEnable(jsonReader.nextBoolean());
+ } else {
+ // 字段不匹配,返回 false 表示需要跳过该字段
+ return false;
+ }
+ // 字段解析成功
+ return true;
+ }
+
+ /**
+ * 重写父类方法:从 JSON 读取器完整解析配置对象(入口方法)
+ * 负责 JSON 对象的开始/结束解析,遍历所有字段并调用 initObjectsFromJsonReader 处理
+ * @param jsonReader JSON 读取器(传入待解析的 JSON 流)
+ * @return 解析后的当前 LogUtilsClassTAGBean 实例(支持链式调用)
+ * @throws IOException JSON 解析异常(如格式错误、字段缺失)
+ */
+ @Override
+ public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException {
+ // 开始解析 JSON 对象(必须与 writeThisToJsonWriter 中的结构对应)
+ jsonReader.beginObject();
+ // 遍历 JSON 中的所有字段
+ while (jsonReader.hasNext()) {
+ String fieldName = jsonReader.nextName();
+ // 解析字段,若字段不匹配则跳过该值(避免解析失败)
+ if (!this.initObjectsFromJsonReader(jsonReader, fieldName)) {
+ jsonReader.skipValue();
+ }
+ }
+ // 结束 JSON 对象解析(必须调用,否则会导致流异常)
+ jsonReader.endObject();
+ // 返回当前实例,支持链式调用(如 new LogUtilsClassTAGBean().readBeanFromJsonReader(reader))
+ return this;
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogView.java
new file mode 100644
index 0000000..56f5d38
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogView.java
@@ -0,0 +1,553 @@
+package cc.winboll.studio.libappbase;
+
+/**
+ * @Author ZhanGSKen@QQ.COM
+ * @Date 2024/08/12 14:36:18
+ * @Describe 日志视图类,继承 RelativeLayout 类。
+ */
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.Handler;
+import android.os.Message;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.BaseAdapter;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.HorizontalScrollView;
+import android.widget.RelativeLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.R;
+import cc.winboll.studio.libappbase.views.HorizontalListView;
+import cc.winboll.studio.libappbase.widget.LogTagSpinner;
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+public class LogView extends RelativeLayout {
+
+ public static final String TAG = "LogView";
+
+ public volatile boolean mIsHandling;
+ public volatile boolean mIsAddNewLog;
+
+ Context mContext;
+ ScrollView mScrollView;
+ TextView mTextView;
+ EditText metTagSearch;
+ CheckBox mSelectableCheckBox;
+ CheckBox mSelectAllTAGCheckBox;
+ TAGListAdapter mTAGListAdapter;
+ LogViewThread mLogViewThread;
+ LogViewHandler mLogViewHandler;
+ LogTagSpinner mLogLevelSpinner;
+ ArrayAdapter mLogLevelSpinnerAdapter;
+ // 标签列表
+ HorizontalListView mListViewTags;
+
+ public LogView(Context context) {
+ super(context);
+ initView(context);
+ }
+
+ public LogView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initView(context);
+ }
+
+ public LogView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initView(context);
+ }
+
+ public LogView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ initView(context);
+ }
+
+ public void start() {
+ mLogViewThread = new LogViewThread(LogView.this);
+ mLogViewThread.start();
+ // 显示日志
+ showAndScrollLogView();
+ }
+
+ public void scrollLogUp() {
+ mScrollView.post(new Runnable() {
+ @Override
+ public void run() {
+ mScrollView.fullScroll(ScrollView.FOCUS_DOWN);
+ // 日志显示结束
+ mLogViewHandler.setIsHandling(false);
+ // 检查是否添加了新日志
+ if (mLogViewHandler.isAddNewLog()) {
+ // 有新日志添加,先更改新日志标志
+ mLogViewHandler.setIsAddNewLog(false);
+ // 再次发送显示日志的显示
+ Message message = mLogViewHandler.obtainMessage(LogViewHandler.MSG_LOGVIEW_UPDATE);
+ mLogViewHandler.sendMessage(message);
+ }
+ }
+ });
+ }
+
+ void initView(Context context) {
+ mContext = context;
+ mLogViewHandler = new LogViewHandler();
+ // 加载视图布局
+ addView(inflate(mContext, cc.winboll.studio.libappbase.R.layout.view_log, null));
+ // 初始化日志子控件视图
+ //
+ mScrollView = findViewById(cc.winboll.studio.libappbase.R.id.viewlogScrollViewLog);
+ mTextView = findViewById(cc.winboll.studio.libappbase.R.id.viewlogTextViewLog);
+ metTagSearch = findViewById(cc.winboll.studio.libappbase.R.id.tagsearch_et);
+ // 获取Log Level spinner实例
+ mLogLevelSpinner = findViewById(cc.winboll.studio.libappbase.R.id.viewlogSpinner1);
+
+ metTagSearch.setTextColor(mContext.getResources().getColor(R.color.white));
+ metTagSearch.addTextChangedListener(new TextWatcher() {
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int p, int p1, int p2) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ LogUtils.d(TAG, s.toString());
+ if (s.length() > 0) {
+ scrollToTag(s.toString());
+ } else {
+ HorizontalScrollView hsRoot = findViewById(R.id.viewlogHorizontalScrollView1);
+ hsRoot.smoothScrollTo(0, 0);
+ mListViewTags.resetScrollToStart();
+ }
+// mListViewTags.postDelayed(new Runnable() {
+// @Override
+// public void run() {
+// mListViewTags.scrollToItem(5);
+// }
+// }, 100);
+ }
+ // 其他方法留空或按需实现
+ });
+
+
+ (findViewById(cc.winboll.studio.libappbase.R.id.viewlogButtonClean)).setOnClickListener(new View.OnClickListener(){
+
+ @Override
+ public void onClick(View v) {
+ LogUtils.cleanLog();
+ LogUtils.d(TAG, "Log is cleaned.");
+ }
+ });
+ (findViewById(cc.winboll.studio.libappbase.R.id.viewlogButtonCopy)).setOnClickListener(new View.OnClickListener(){
+
+ @Override
+ public void onClick(View v) {
+
+ ClipboardManager cm = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE);
+ cm.setPrimaryClip(ClipData.newPlainText(mContext.getPackageName(), LogUtils.loadLog()));
+ LogUtils.d(TAG, "Log is copied.");
+ }
+ });
+ mSelectableCheckBox = findViewById(cc.winboll.studio.libappbase.R.id.viewlogCheckBoxSelectable);
+ mSelectableCheckBox.setOnClickListener(new View.OnClickListener(){
+ @Override
+ public void onClick(View v) {
+ if (mSelectableCheckBox.isChecked()) {
+ setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
+ } else {
+ setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
+ }
+ }
+ });
+
+ // 设置日志级别列表
+// ArrayList adapterItems = new ArrayList<>();
+// for (LogUtils.LOG_LEVEL e : LogUtils.LOG_LEVEL.values()) {
+// adapterItems.add(e.name());
+// }
+ String[] mLogLevelSpinnerData = new String[LogUtils.LOG_LEVEL.values().length];
+ for (int i = 0; i < LogUtils.LOG_LEVEL.values().length; i++) {
+ mLogLevelSpinnerData[i] = LogUtils.LOG_LEVEL.values()[i].name();
+ }
+ mLogLevelSpinner.setLogTagData(mLogLevelSpinnerData);
+ // 假设你有一个字符串数组作为选项列表
+ //String[] options = {"Option 1", "Option 2", "Option 3"};
+ // 创建一个ArrayAdapter来绑定数据到spinner
+// mLogLevelSpinnerAdapter = ArrayAdapter.createFromResource(
+// context, cc.winboll.studio.libappbase.R.array.enum_loglevel_array, android.R.layout.simple_spinner_item);
+// mLogLevelSpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+
+// // 设置适配器并将它应用到spinner上
+// mLogLevelSpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); // 设置下拉视图样式
+// mLogLevelSpinner.setAdapter(mLogLevelSpinnerAdapter);
+ // 为Spinner添加监听器
+ mLogLevelSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ //String selectedOption = mLogLevelSpinnerAdapter.getItem(position);
+ // 处理选中的选项...
+ LogUtils.setLogLevel(LogUtils.LOG_LEVEL.values()[position]);
+ }
+ @Override
+ public void onNothingSelected(AdapterView> parent) {
+ // 如果没有选择,则执行此操作...
+ }
+ });
+ // 获取默认值的索引
+ int defaultValueIndex = LogUtils.getLogLevel().ordinal();
+
+ if (defaultValueIndex != -1) {
+ // 如果找到了默认值,设置默认选项
+ mLogLevelSpinner.setSelection(defaultValueIndex);
+ }
+
+ // 加载标签列表
+ Map mapTAGList = LogUtils.getMapTAGList();
+ boolean isAllSelect = true;
+ for (Map.Entry entry : mapTAGList.entrySet()) {
+ if (entry.getValue() == false) {
+ isAllSelect = false;
+ break;
+ }
+ }
+ CheckBox cbALLTAG = findViewById(cc.winboll.studio.libappbase.R.id.viewlogCheckBox1);
+ cbALLTAG.setChecked(isAllSelect);
+
+ // 加载标签表
+ mListViewTags = findViewById(cc.winboll.studio.libappbase.R.id.tags_listview);
+ mListViewTags.setVerticalOffset(10);
+ mTAGListAdapter = new TAGListAdapter(mContext, mapTAGList);
+ mListViewTags.setAdapter(mTAGListAdapter);
+
+ // 可以添加点击监听器来处理勾选框状态变化后的逻辑,比如获取当前勾选情况等
+ mTAGListAdapter.notifyDataSetChanged();
+
+ mSelectAllTAGCheckBox = findViewById(cc.winboll.studio.libappbase.R.id.viewlogCheckBox1);
+ ViewGroup.LayoutParams layoutParams2 = mSelectAllTAGCheckBox.getLayoutParams();
+ if (layoutParams2 != null) {
+ layoutParams2.width = ViewGroup.LayoutParams.WRAP_CONTENT;
+ layoutParams2.height = 75;
+ }
+ mSelectAllTAGCheckBox.setLayoutParams(layoutParams2);
+ //mSelectAllTAGCheckBox.setPadding(0,0,0,0);
+ TypedArray ta1 = mContext.obtainStyledAttributes(new int[] { R.attr.toolbarTextColor });
+ int toolbarTextColor1 = ta1.getColor(0, mContext.getResources().getColor(R.color.white));
+ ta1.recycle();
+ mSelectAllTAGCheckBox.setTextColor(toolbarTextColor1);
+ mSelectAllTAGCheckBox.setOnClickListener(new View.OnClickListener(){
+ @Override
+ public void onClick(View v) {
+ LogUtils.setALlTAGListEnable(mSelectAllTAGCheckBox.isChecked());
+ //LogUtils.setALlTAGListEnable(false);
+ //mTAGListAdapter.notifyDataSetChanged();
+ mTAGListAdapter.reload();
+ //ToastUtils.show(String.format("onClick\nmSelectAllTAGCheckBox.isChecked() : %s", mSelectAllTAGCheckBox.isChecked()));
+ }
+ });
+
+
+ mLogLevelSpinner.updateTextSize(R.dimen.log_spinner_text_size);
+
+ // 设置滚动时不聚焦日志
+ setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
+ }
+
+ public void updateLogView() {
+ if (mLogViewHandler.isHandling() == true) {
+ // 正在处理日志显示,
+ // 就先设置一个新日志标志位
+ // 以便日志显示完后,再次显示新日志内容
+ mLogViewHandler.setIsAddNewLog(true);
+ } else {
+ //LogUtils.d(TAG, "LogListener showLog(String path)");
+ Message message = mLogViewHandler.obtainMessage(LogViewHandler.MSG_LOGVIEW_UPDATE);
+ mLogViewHandler.sendMessage(message);
+ mLogViewHandler.setIsAddNewLog(false);
+ }
+ }
+
+ void showAndScrollLogView() {
+ mTextView.setText(LogUtils.loadLog());
+ scrollLogUp();
+ }
+
+ public void scrollToTag(final String prefix) {
+ if (mTAGListAdapter == null || prefix == null || prefix.length() == 0) {
+ LogUtils.d(TAG, "参数为空,无法滚动");
+ return;
+ }
+
+ final List itemList = mTAGListAdapter.getItemList();
+
+ mListViewTags.post(new Runnable() {
+ @Override
+ public void run() {
+ // 查找匹配的标签位置
+ int targetPosition = -1;
+
+ for (int i = 0; i < itemList.size(); i++) {
+ String tag = itemList.get(i).getTag();
+ if (tag != null && tag.toLowerCase().startsWith(prefix.toLowerCase())) {
+ targetPosition = i;
+
+ break;
+ }
+ }
+
+ if (targetPosition != -1) {
+ // 优化滚动逻辑
+ //mListViewTags.setSelection(targetPosition);
+ //mListViewTags.invalidateViews(); // 强制刷新所有可见项
+
+ // 单独刷新目标视图
+// View targetView = mListViewTags.getChildAt(targetPosition);
+// if (targetView != null) {
+// targetView.requestLayout();
+// targetView.requestFocus();
+// }
+
+ final int scrollPosition = targetPosition;
+
+ // 延迟滚动确保布局完成
+ mListViewTags.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ LogUtils.d(TAG, String.format("scrollPosition %d", scrollPosition));
+ mListViewTags.scrollToItem(scrollPosition);
+ }
+ }, 100);
+ } else {
+ LogUtils.d(TAG, "未找到匹配的标签前缀:" + prefix);
+ }
+ }
+ });
+ }
+
+
+
+ class LogViewHandler extends Handler {
+
+ final static int MSG_LOGVIEW_UPDATE = 0;
+ volatile boolean isHandling;
+ volatile boolean isAddNewLog;
+
+ public LogViewHandler() {
+ setIsHandling(false);
+ setIsAddNewLog(false);
+ }
+
+ public void setIsHandling(boolean isHandling) {
+ this.isHandling = isHandling;
+ }
+
+ public boolean isHandling() {
+ return isHandling;
+ }
+
+ public void setIsAddNewLog(boolean isAddNewLog) {
+ this.isAddNewLog = isAddNewLog;
+ }
+
+ public boolean isAddNewLog() {
+ return isAddNewLog;
+ }
+
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_LOGVIEW_UPDATE:{
+ if (isHandling() == false) {
+ setIsHandling(true);
+ showAndScrollLogView();
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ super.handleMessage(msg);
+ }
+ }
+
+ public class TAGItemModel {
+ private String tag;
+ private boolean isChecked;
+
+ public TAGItemModel(String tag, boolean isChecked) {
+ this.tag = tag;
+ this.isChecked = isChecked;
+ }
+
+ public String getTag() {
+ return tag;
+ }
+
+ public void setTag(String tag) {
+ this.tag = tag;
+ }
+
+ public boolean isChecked() {
+ return isChecked;
+ }
+
+ public void setChecked(boolean checked) {
+ isChecked = checked;
+ }
+
+ // getter/setter...
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ TAGItemModel that = (TAGItemModel) o;
+ // 手动处理空值比较(Java 6 不支持 Objects.equals)
+ if (tag == null) {
+ return that.tag == null;
+ } else {
+ return tag.equals(that.tag);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return tag == null ? 0 : tag.hashCode(); // 手动处理空值
+ }
+ }
+
+
+ public class TAGListAdapter extends BaseAdapter {
+
+ private Context context;
+ private Map mapOrigin;
+ private List itemList;
+
+ public TAGListAdapter(Context context, Map map) {
+ this.context = context;
+ mapOrigin = map;
+ loadMap(mapOrigin);
+ }
+
+ public List getItemList() {
+ return itemList;
+ }
+
+ @Override
+ public int getCount() {
+ return itemList.size();
+ }
+
+ @Override
+ public Object getItem(int p) {
+ return itemList.get(p);
+ }
+
+ @Override
+ public long getItemId(int p) {
+ return p;
+ }
+
+ void loadMap(Map map) {
+ itemList = new ArrayList();
+ for (Map.Entry entry : map.entrySet()) {
+ itemList.add(new TAGItemModel(entry.getKey(), entry.getValue()));
+ }
+ // 添加排序功能,按照tag进行升序排序
+ Collections.sort(itemList, new SortMapEntryByKeyString(true));
+ //Collections.sort(itemList, new SortMapEntryByKeyString(false));
+ }
+
+ public void reload() {
+ loadMap(mapOrigin);
+ super.notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ ViewHolder holder;
+ if (convertView == null) {
+ convertView = LayoutInflater.from(context).inflate(R.layout.item_logtag, parent, false);
+ holder = new ViewHolder();
+ holder.tvText = convertView.findViewById(R.id.viewlogtagTextView1);
+ holder.cbChecked = convertView.findViewById(R.id.viewlogtagCheckBox1);
+ convertView.setTag(holder);
+ } else {
+ holder = (ViewHolder) convertView.getTag();
+ }
+
+ final TAGItemModel item = itemList.get(position);
+ holder.tvText.setText(item.getTag());
+ ViewGroup.LayoutParams layoutParams = holder.tvText.getLayoutParams();
+ if (layoutParams != null) {
+ layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
+ layoutParams.height = 75;
+ }
+ holder.tvText.setLayoutParams(layoutParams);
+ holder.tvText.setPadding(0,0,0,0);
+ TypedArray ta2 = mContext.obtainStyledAttributes(new int[] { R.attr.toolbarTextColor });
+ int toolbarTextColor2 = ta2.getColor(0, mContext.getResources().getColor(R.color.white));
+ ta2.recycle();
+ holder.tvText.setTextColor(toolbarTextColor2);
+ holder.cbChecked.setChecked(item.isChecked());
+ holder.cbChecked.setLayoutParams(layoutParams);
+ holder.cbChecked.setPadding(0,0,0,0);
+ TypedArray ta3 = mContext.obtainStyledAttributes(new int[] { R.attr.toolbarTextColor });
+ int toolbarTextColor3 = ta3.getColor(0, mContext.getResources().getColor(R.color.white));
+ ta3.recycle();
+ holder.cbChecked.setTextColor(toolbarTextColor3);
+ holder.cbChecked.setOnClickListener(new View.OnClickListener(){
+
+ @Override
+ public void onClick(View v) {
+ LogUtils.setTAGListEnable(item.getTag(), ((CheckBox)v).isChecked());
+ }
+ });
+
+ return convertView;
+ }
+
+ public class ViewHolder {
+ TextView tvText;
+ CheckBox cbChecked;
+ }
+ }
+
+ class SortMapEntryByKeyString implements Comparator {
+ private boolean mIsDesc = true;
+ // isDesc 是否降序排列
+ public SortMapEntryByKeyString(boolean isDesc) {
+ mIsDesc = isDesc;
+ }
+ Collator cmp = Collator.getInstance(java.util.Locale.CHINA);
+ @Override
+ public int compare(TAGItemModel o1, TAGItemModel o2) {
+ if (mIsDesc) {
+ return o1.getTag().compareTo(o2.getTag());
+ } else {
+ return o2.getTag().compareTo(o1.getTag());
+ }
+ }
+ }
+}
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/LogViewThread.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogViewThread.java
new file mode 100644
index 0000000..3941e45
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/LogViewThread.java
@@ -0,0 +1,140 @@
+package cc.winboll.studio.libappbase;
+
+import android.os.FileObserver;
+import java.lang.ref.WeakReference;
+
+/**
+ * @Author ZhanGSKen
+ * @Date 2024/08/12 14:43:50
+ * @Describe 日志视图线程类
+ * 独立线程监听日志文件目录变化(如写入、删除),触发日志视图更新,避免阻塞主线程
+ */
+public class LogViewThread extends Thread {
+
+ /** 日志标签(用于调试输出) */
+ public static final String TAG = "LogViewThread";
+
+ /** 线程退出标志(volatile 保证多线程可见性,控制循环退出) */
+ private volatile boolean isExit = false;
+ /** 日志文件目录监听实例(监听文件写入、删除事件) */
+ private LogListener mLogListener;
+ /** 日志视图弱引用(避免持有 LogView 强引用导致内存泄漏) */
+ private final WeakReference mLogViewWeakRef;
+
+ /**
+ * 构造函数
+ * @param logView 日志显示视图实例(需通过弱引用持有,避免内存泄漏)
+ */
+ public LogViewThread(LogView logView) {
+ // 使用弱引用包装 LogView,当视图销毁时可被 GC 回收
+ mLogViewWeakRef = new WeakReference<>(logView);
+ }
+
+ /**
+ * 设置线程退出标志(触发线程停止监听并退出)
+ * @param exit true:退出线程;false:继续运行(默认)
+ */
+ public void setExit(boolean exit) {
+ this.isExit = exit;
+ }
+
+ /**
+ * 获取当前线程退出状态
+ * @return true:已标记退出;false:运行中
+ */
+ public boolean isExit() {
+ return isExit;
+ }
+
+ /**
+ * 线程核心逻辑:初始化文件监听并启动循环,直到收到退出标志
+ */
+ @Override
+ public void run() {
+ // 获取日志缓存目录路径(从 LogUtils 统一获取,确保路径一致性)
+ String logDirPath = LogUtils.getLogCacheDir().getPath();
+ LogUtils.d(TAG, "启动日志文件监听,监听目录:" + logDirPath);
+
+ // 初始化日志文件监听器(监听目标目录的文件事件)
+ mLogListener = new LogListener(logDirPath);
+ // 开始监听文件事件(非阻塞,内部通过 Native 层实现)
+ mLogListener.startWatching();
+
+ // 循环等待退出标志(每 1 秒检查一次,降低 CPU 占用)
+ while (!isExit()) {
+ try {
+ Thread.sleep(1000); // 休眠 1 秒,避免忙等
+ } catch (InterruptedException e) {
+ // 线程被中断时,恢复中断标志并退出循环(避免无限阻塞)
+ Thread.currentThread().interrupt();
+ LogUtils.d(TAG, "日志监听线程被中断,准备退出。" + e);
+ break;
+ }
+ }
+
+ // 收到退出标志,停止监听并释放资源
+ mLogListener.stopWatching();
+ LogUtils.d(TAG, "日志文件监听已停止,线程退出");
+ }
+
+ /**
+ * 日志文件监听内部类(继承 FileObserver,监听目录下文件变化)
+ * 仅关注文件写入完成(CLOSE_WRITE)和文件删除(DELETE)事件
+ */
+ private class LogListener extends FileObserver {
+
+ /**
+ * 构造函数
+ * @param path 监听的目录路径(此处为日志缓存目录)
+ */
+ public LogListener(String path) {
+ // 父类构造:监听指定目录的所有事件(通过位掩码 ALL_EVENTS 指定)
+ super(path);
+ }
+
+ /**
+ * 文件事件回调(运行在系统私有线程,非主线程)
+ * @param event 事件类型(通过位掩码表示,需与 ALL_EVENTS 按位与解析)
+ * @param path 发生事件的文件名(相对监听目录的路径)
+ */
+ @Override
+ public void onEvent(int event, String path) {
+ // 解析事件类型(排除无关事件,只处理目标事件)
+ int eventType = event & FileObserver.ALL_EVENTS;
+
+ switch (eventType) {
+ // 事件:文件写入完成(如日志写入结束并关闭文件)
+ case FileObserver.CLOSE_WRITE:
+ // 触发日志视图更新(需先判断 LogView 是否未被回收)
+ updateLogView();
+ break;
+
+ // 事件:文件被删除(如日志清理操作)
+ case FileObserver.DELETE:
+ LogUtils.d(TAG, "日志文件被删除,文件名:" + (path != null ? path : "未知"));
+ // 触发日志视图更新(刷新视图显示空状态)
+ updateLogView();
+ break;
+
+ default:
+ // 忽略其他无关事件(如文件创建、访问等)
+ break;
+ }
+ }
+
+ /**
+ * 触发日志视图更新(通过弱引用获取 LogView,避免内存泄漏)
+ */
+ private void updateLogView() {
+ // 从弱引用中获取 LogView 实例(若视图已销毁,get() 返回 null)
+ LogView logView = mLogViewWeakRef.get();
+ if (logView != null) {
+ // 调用 LogView 的更新方法(需确保 updateLogView 内部处理主线程切换)
+ logView.updateLogView();
+ } else {
+ LogUtils.w(TAG, "LogView 已被回收,无法更新日志视图");
+ }
+ }
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/ToastUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/ToastUtils.java
new file mode 100644
index 0000000..a69fc37
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/ToastUtils.java
@@ -0,0 +1,268 @@
+package cc.winboll.studio.libappbase;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.widget.Toast;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/11 20:51
+ * @Describe 吐司工具类(单例模式)
+ * 简化 Android 吐司的创建与展示,通过独立线程 + Handler 处理消息,最终切换到主线程显示吐司,避免内存泄漏
+ */
+public class ToastUtils {
+
+ /** 工具类日志 TAG(用于调试输出) */
+ public static final String TAG = "ToastUtils";
+ /** 消息标识:显示短时长吐司 */
+ private static final int MSG_SHOW_SHORT_TOAST = 1001;
+
+ /** 单例实例(volatile 保证多线程下可见性,避免指令重排) */
+ private static volatile ToastUtils sInstance;
+ /** 全局上下文(volatile 保证多线程可见性,避免空指针) */
+ private volatile Context mContext;
+ /** 独立线程的 Handler(volatile 保证可见性) */
+ private volatile Handler mWorkerHandler;
+ /** 主线程 Handler(volatile 保证可见性) */
+ private volatile Handler mMainHandler;
+ /** 消息处理独立线程 */
+ private Thread mWorkerThread;
+ /** 资源释放标记(volatile 避免多线程误操作) */
+ private volatile boolean isReleased = false;
+
+ /**
+ * 私有构造方法(禁止外部直接创建实例,确保单例)
+ * 1. 初始化主线程 Handler;
+ * 2. 创建并启动独立消息处理线程。
+ */
+ private ToastUtils() {
+ initMainHandler(); // 优先初始化主线程 Handler
+ startWorkerThread(); // 启动独立消息处理线程
+ }
+
+ /**
+ * 初始化主线程 Handler
+ */
+ private void initMainHandler() {
+ if (Looper.getMainLooper() == null) {
+ LogUtils.e(TAG, "主线程 Looper 为空,无法初始化 mMainHandler");
+ throw new IllegalStateException("主线程 Looper 未初始化,无法创建 ToastUtils");
+ }
+ mMainHandler = new Handler(Looper.getMainLooper());
+ LogUtils.d(TAG, "主线程 Handler 初始化完成,线程ID:" + Looper.getMainLooper().getThread().getId());
+ }
+
+ /**
+ * 启动独立消息处理线程
+ */
+ private void startWorkerThread() {
+ mWorkerThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ LogUtils.d(TAG, "消息处理线程启动,线程ID:" + Thread.currentThread().getId());
+ Looper.prepare();
+
+ mWorkerHandler = new Handler(Looper.myLooper()) {
+ @Override
+ public void handleMessage(Message msg) {
+ super.handleMessage(msg);
+ // 若已释放,直接返回,不处理消息
+ if (isReleased) {
+ LogUtils.w(TAG, "资源已释放,忽略消息处理");
+ return;
+ }
+ LogUtils.d(TAG, "WorkerHandler 接收消息,当前线程ID:" + Thread.currentThread().getId());
+ if (msg.what == MSG_SHOW_SHORT_TOAST && msg.obj != null) {
+ String message = (String) msg.obj;
+ postToMainThreadShowToast(message);
+ }
+ }
+ };
+
+ Looper.loop();
+ LogUtils.d(TAG, "消息处理线程退出");
+ }
+ }, "ToastWorkerThread");
+ mWorkerThread.start();
+ }
+
+ /**
+ * 获取单例实例(双重检查锁定)
+ * @return ToastUtils 单例对象
+ */
+ private static ToastUtils getInstance() {
+ if (sInstance == null) {
+ synchronized (ToastUtils.class) {
+ if (sInstance == null) {
+ sInstance = new ToastUtils();
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ /**
+ * 初始化工具类(必须在 Application 启动时调用)
+ * @param context 全局上下文(推荐 Application 上下文)
+ */
+ public static void init(Context context) {
+ if (context == null) {
+ throw new IllegalArgumentException("初始化上下文不能为 null!");
+ }
+ ToastUtils instance = getInstance();
+ // 若已释放,重置释放标记
+ if (instance.isReleased) {
+ instance.isReleased = false;
+ instance.startWorkerThread(); // 重新启动线程
+ }
+ instance.mContext = context.getApplicationContext();
+ LogUtils.d(TAG, "ToastUtils 初始化完成,上下文已设置");
+ }
+
+ // ===================================== 新增:isInited() 方法 =====================================
+ /**
+ * 判断 ToastUtils 是否已初始化(供外部调用,如 CrashHandleNotifyUtils 中的复制提示)
+ * @return true:已初始化(可正常显示吐司);false:未初始化/已释放(无法正常显示)
+ */
+ public static boolean isInited() {
+ ToastUtils instance = getInstance();
+ // 双重校验:1. 未释放 2. 上下文已设置(确保初始化完成)
+ return !instance.isReleased && instance.mContext != null;
+ }
+ // ===================================== 新增结束 =====================================
+
+ /**
+ * 外部接口:显示短时长吐司
+ * @param message 吐司内容
+ */
+ public static void show(String message) {
+ LogUtils.d(TAG, "外部调用 show(),当前线程ID:" + Thread.currentThread().getId());
+ if (message == null || message.isEmpty()) {
+ return;
+ }
+
+ ToastUtils instance = getInstance();
+ // 校验资源是否已释放
+ if (instance.isReleased) {
+ LogUtils.w(TAG, "ToastUtils 已释放,无法显示吐司:" + message);
+ return;
+ }
+ // 校验上下文是否初始化
+ if (instance.mContext == null) {
+ LogUtils.e(TAG, "ToastUtils 未初始化!请先调用 init(Context) 方法");
+ // 不抛出异常,避免崩溃,改为日志提示
+ return;
+ }
+
+ instance.sendToastMessage(message);
+ }
+
+ /**
+ * 发送吐司消息到 WorkerHandler
+ * @param message 吐司内容
+ */
+ private void sendToastMessage(String message) {
+ LogUtils.d(TAG, "发送消息到 WorkerHandler");
+ // 校验 WorkerHandler 是否就绪
+ if (mWorkerHandler == null) {
+ LogUtils.w(TAG, "WorkerHandler 未就绪,直接主线程显示");
+ postToMainThreadShowToast(message);
+ return;
+ }
+ // 发送消息
+ Message msg = mWorkerHandler.obtainMessage(MSG_SHOW_SHORT_TOAST);
+ msg.obj = message;
+ mWorkerHandler.sendMessage(msg);
+ }
+
+ /**
+ * 切换到主线程显示吐司
+ * @param message 吐司内容
+ */
+ private void postToMainThreadShowToast(final String message) {
+ LogUtils.d(TAG, "切换到主线程显示吐司,当前线程ID:" + Thread.currentThread().getId());
+ // 校验资源是否已释放
+ if (isReleased) {
+ LogUtils.w(TAG, "资源已释放,取消显示吐司");
+ return;
+ }
+ // 校验并初始化 mMainHandler
+ if (mMainHandler == null) {
+ LogUtils.e(TAG, "mMainHandler 为空,尝试重新初始化");
+ initMainHandler();
+ if (mMainHandler == null) {
+ LogUtils.e(TAG, "mMainHandler 初始化失败,无法显示吐司:" + message);
+ return;
+ }
+ }
+ // 主线程显示
+ mMainHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ if (isReleased) return; // 释放后取消执行
+ showToastInternal(message);
+ }
+ });
+ }
+
+ /**
+ * 实际显示吐司(主线程)
+ * @param message 吐司内容
+ */
+ private void showToastInternal(String message) {
+ LogUtils.d(TAG, "执行 showToastInternal()");
+ // 最终校验上下文
+ if (mContext == null) {
+ LogUtils.w(TAG, "上下文为空,无法显示吐司:" + message);
+ // 尝试重新获取 Application 上下文(降级策略)
+ Context appContext = GlobalApplication.getInstance();
+ if (appContext != null) {
+ mContext = appContext;
+ Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show();
+ LogUtils.d(TAG, "通过 GlobalApplication 获取上下文,成功显示吐司");
+ }
+ return;
+ }
+ Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show();
+ }
+
+ /**
+ * 释放资源(仅在应用退出时调用)
+ */
+ public static void release() {
+ LogUtils.d(TAG, "开始释放 ToastUtils 资源");
+ ToastUtils instance = getInstance();
+ // 标记为已释放,阻止后续消息处理
+ instance.isReleased = true;
+
+ // 停止 Worker 线程
+ if (instance.mWorkerHandler != null && instance.mWorkerHandler.getLooper() != null) {
+ instance.mWorkerHandler.getLooper().quit();
+ instance.mWorkerHandler = null;
+ }
+
+ // 清理主线程 Handler
+ if (instance.mMainHandler != null) {
+ instance.mMainHandler.removeCallbacksAndMessages(null);
+ instance.mMainHandler = null;
+ }
+
+ // 等待线程退出
+ if (instance.mWorkerThread != null && instance.mWorkerThread.isAlive()) {
+ try {
+ instance.mWorkerThread.join(1000);
+ } catch (InterruptedException e) {
+ LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
+ Thread.currentThread().interrupt();
+ }
+ instance.mWorkerThread = null;
+ }
+
+ // 清空上下文(避免内存泄漏)
+ instance.mContext = null;
+ LogUtils.d(TAG, "ToastUtils 资源释放完成");
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/UTF8FileUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/UTF8FileUtils.java
new file mode 100644
index 0000000..c7407fb
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/UTF8FileUtils.java
@@ -0,0 +1,86 @@
+package cc.winboll.studio.libappbase;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/11/11 20:45
+ * @Describe UTF-8 编码文件操作工具类
+ * 提供字符串与文件的相互转换,强制使用 UTF-8 编码,确保跨平台字符兼容性
+ */
+public class UTF8FileUtils {
+
+ /** 工具类日志 TAG(用于调试输出) */
+ public static final String TAG = "UTF8FileUtils";
+
+ /**
+ * 将字符串写入文件(强制 UTF-8 编码)
+ * 若文件父目录不存在,自动创建;覆盖原有文件内容
+ * @param filePath 文件路径(包含文件名,如 "/sdcard/test.txt")
+ * @param content 要写入的字符串内容
+ * @throws IOException 写入失败时抛出(如权限不足、路径无效等)
+ */
+ public static void writeStringToFile(String filePath, String content) throws IOException {
+ // 根据路径创建文件对象
+ File file = new File(filePath);
+ // 获取父目录,若不存在则递归创建
+ File parentDir = file.getParentFile();
+ if (parentDir != null && !parentDir.exists()) {
+ parentDir.mkdirs();
+ }
+
+ // 初始化文件输出流(覆盖模式)
+ FileOutputStream outputStream = new FileOutputStream(file);
+ // 包装为 UTF-8 编码的字符输出流
+ OutputStreamWriter writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
+
+ try {
+ // 写入字符串内容
+ writer.write(content);
+ } finally {
+ // 强制关闭流,避免资源泄漏(即使写入失败也确保流关闭)
+ writer.close();
+ }
+ }
+
+ /**
+ * 从文件读取字符串(强制 UTF-8 编码)
+ * 逐字符读取文件内容,拼接为完整字符串返回
+ * @param filePath 文件路径(包含文件名,如 "/sdcard/test.txt")
+ * @return 文件内容字符串(空文件返回空字符串)
+ * @throws IOException 读取失败时抛出(如文件不存在、权限不足等)
+ */
+ public static String readStringFromFile(String filePath) throws IOException {
+ // 根据路径创建文件对象
+ File file = new File(filePath);
+ // 初始化文件输入流
+ FileInputStream inputStream = new FileInputStream(file);
+ // 包装为 UTF-8 编码的字符输入流
+ InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
+
+ // 字符串构建器,用于拼接读取的字符
+ StringBuilder content = new StringBuilder();
+ int charCode; // 存储单个字符的 ASCII 码
+
+ try {
+ // 逐字符读取(-1 表示读取到文件末尾)
+ while ((charCode = reader.read()) != -1) {
+ // 将 ASCII 码转换为字符,追加到字符串
+ content.append((char) charCode);
+ }
+ } finally {
+ // 强制关闭流,避免资源泄漏
+ reader.close();
+ }
+
+ // 返回读取的完整字符串
+ return content.toString();
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/activities/NfcRsaLoginActivity.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/activities/NfcRsaLoginActivity.java
new file mode 100644
index 0000000..0c96bfb
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/activities/NfcRsaLoginActivity.java
@@ -0,0 +1,328 @@
+package cc.winboll.studio.libappbase.activities;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.nfc.NfcAdapter;
+import android.nfc.Tag;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.R;
+import cc.winboll.studio.libappbase.utils.NfcRsaAuthTool;
+
+/**
+ * @Describe NFC RSA登录认证窗口
+ * 核心逻辑:贴近NFC→有密钥显示保存按钮(存应用data区+内存缓存)→无密钥启用初始化按钮(生成私钥写NFC)
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/11 20:34:00
+ * @LastEditTime 2026/01/12 16:28:00
+ */
+public class NfcRsaLoginActivity extends Activity implements View.OnClickListener {
+ // 常量定义
+ private static final String TAG = "NfcRsaLoginActivity";
+
+ // NFC核心相关属性
+ private NfcAdapter mNfcAdapter;
+ private PendingIntent mNfcPendingIntent;
+
+ // 视图控件相关属性
+ private TextView mTvNfcState;
+ private TextView mTvPrivateKey;
+ private TextView mTvPublicKey;
+ private Button mBtnOptKey; // 复用按钮:有密钥=保存本地,无密钥=初始化密钥
+
+ // 业务相关属性
+ private NfcRsaAuthTool mNfcRsaAuthTool;
+ private boolean isPreparingInit = false; // 标记是否准备初始化密钥(替代原写入标记)
+ private String mTempPrivateKey; // 临时存储NFC读取的有效私钥,用于后续保存
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_nfc_rsa_operate);
+ initView();
+ initNfcTool();
+ initNfcConfig();
+ LogUtils.d(TAG, "onCreate: NFC RSA登录窗口初始化完成");
+ }
+
+ /**
+ * 初始化视图控件,绑定点击事件,默认状态配置
+ */
+ private void initView() {
+ mTvNfcState = findViewById(R.id.tv_nfc_state);
+ mTvPrivateKey = findViewById(R.id.tv_private_key);
+ mTvPublicKey = findViewById(R.id.tv_public_key);
+ mBtnOptKey = findViewById(R.id.btn_create_write_key);
+
+ mBtnOptKey.setOnClickListener(this);
+ mBtnOptKey.setEnabled(false);
+ mTvNfcState.setText("正在监听NFC卡片,请贴近设备检测密钥...");
+ mTvPrivateKey.setText("私钥内容:无");
+ mTvPublicKey.setText("公钥内容:无");
+ LogUtils.d(TAG, "initView: 视图控件初始化完成,功能按钮默认禁用");
+ }
+
+ /**
+ * 初始化核心工具类NfcRsaAuthTool,校验NFC基础可用性
+ */
+ private void initNfcTool() {
+ mNfcRsaAuthTool = NfcRsaAuthTool.getInstance(this);
+ LogUtils.d(TAG, "initNfcTool: NfcRsaAuthTool单例获取完成");
+
+ if (!mNfcRsaAuthTool.isNfcAvailable()) {
+ mTvNfcState.setText("❌ 设备不支持NFC或未开启NFC");
+ mBtnOptKey.setEnabled(false);
+ Toast.makeText(this, "请先在设置中开启NFC功能", Toast.LENGTH_LONG).show();
+ LogUtils.w(TAG, "initNfcTool: NFC不可用,设备不支持或未开启");
+ } else {
+ LogUtils.d(TAG, "initNfcTool: NFC基础可用性校验通过");
+ }
+ }
+
+ /**
+ * 初始化NFC前台监听配置,页面打开即生效,适配API30
+ */
+ private void initNfcConfig() {
+ mNfcAdapter = NfcAdapter.getDefaultAdapter(this);
+ mNfcPendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ LogUtils.d(TAG, "initNfcConfig: NFC前台监听配置初始化完成,适配API30");
+ }
+
+ /**
+ * 核心分发方法:处理NFC相关意图,区分 密钥检测/密钥初始化 逻辑
+ * @param intent NFC触发的意图对象
+ */
+ private void handleNfcIntent(Intent intent) {
+ if (mNfcRsaAuthTool == null || !mNfcRsaAuthTool.isNfcAvailable()) {
+ LogUtils.w(TAG, "handleNfcIntent: NFC工具类为空或NFC不可用,跳过意图处理");
+ return;
+ }
+
+ String action = intent.getAction();
+ LogUtils.d(TAG, "handleNfcIntent: 收到NFC意图,action=" + action);
+ if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)
+ || NfcAdapter.ACTION_TECH_DISCOVERED.equals(action)
+ || NfcAdapter.ACTION_TAG_DISCOVERED.equals(action)) {
+ Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
+ if (tag != null) {
+ LogUtils.d(TAG, "handleNfcIntent: 成功提取NFC Tag对象,当前初始化准备状态=" + isPreparingInit);
+ if (isPreparingInit) {
+ createWriteAndValidateKey(tag); // 准备初始化:生成+写NFC
+ } else {
+ readAndValidateKey(tag); // 正常状态:检测NFC密钥
+ }
+ } else {
+ LogUtils.w(TAG, "handleNfcIntent: NFC意图中未提取到有效Tag对象");
+ }
+ }
+ }
+
+ /**
+ * 读取NFC中私钥,执行有效性校验,区分场景更新UI(有有效密钥显保存按钮,无则显初始化按钮)
+ * @param tag NFC卡片Tag对象
+ */
+ private void readAndValidateKey(final Tag tag) {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ LogUtils.d(TAG, "readAndValidateKey: 子线程读取NFC私钥,Tag=" + tag);
+ final String privateKeyStr = mNfcRsaAuthTool.readPrivateKeyFromNfc(tag);
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (privateKeyStr != null && !privateKeyStr.isEmpty()) {
+ // NFC读取到私钥,校验有效性
+ boolean priValid = mNfcRsaAuthTool.validatePrivateKey(privateKeyStr);
+ String publicKeyStr = mNfcRsaAuthTool.getCachePublicKeyStr();
+ boolean pubValid = mNfcRsaAuthTool.validatePublicKey(privateKeyStr, publicKeyStr);
+
+ LogUtils.d(TAG, "readAndValidateKey: 私钥读取完成,有效性=" + priValid + ",公钥提取有效性=" + pubValid);
+ if (priValid) {
+ mTempPrivateKey = privateKeyStr; // 缓存有效私钥,用于后续保存
+ mTvNfcState.setText("✅ NFC检测到有效密钥,点击按钮保存到本地");
+ mBtnOptKey.setText("保存密钥到应用本地并缓存");
+ } else {
+ mTvNfcState.setText("⚠️ NFC私钥无效,点击按钮重新初始化");
+ mBtnOptKey.setText("初始化RSA密钥写入NFC");
+ mTempPrivateKey = null;
+ }
+ mTvPrivateKey.setText("私钥内容:\n" + privateKeyStr);
+ mTvPublicKey.setText(publicKeyStr != null ? "公钥内容:\n" + publicKeyStr : "公钥内容:提取失败");
+ mBtnOptKey.setEnabled(true);
+ Toast.makeText(NfcRsaLoginActivity.this, priValid ? "检测到有效密钥" : "密钥无效,请初始化", Toast.LENGTH_SHORT).show();
+ } else {
+ // NFC无有效私钥,显示初始化按钮
+ LogUtils.w(TAG, "readAndValidateKey: NFC中未读取到有效私钥");
+ mTvNfcState.setText("❌ NFC无有效RSA私钥,点击按钮初始化");
+ mTvPrivateKey.setText("私钥内容:无");
+ mTvPublicKey.setText("公钥内容:无");
+ mBtnOptKey.setText("初始化RSA密钥写入NFC");
+ mBtnOptKey.setEnabled(true);
+ mTempPrivateKey = null;
+ Toast.makeText(NfcRsaLoginActivity.this, "未检测到有效私钥", Toast.LENGTH_SHORT).show();
+ }
+ isPreparingInit = false; // 重置初始化标记
+ LogUtils.d(TAG, "readAndValidateKey: 私钥检测流程结束,重置初始化准备状态");
+ }
+ });
+ }
+ }).start();
+ }
+
+ /**
+ * 生成RSA私钥、写入NFC、执行密钥有效性校验并更新UI(初始化密钥核心逻辑)
+ * @param tag NFC卡片Tag对象
+ */
+ private void createWriteAndValidateKey(final Tag tag) {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ LogUtils.d(TAG, "createWriteAndValidateKey: 开始创建私钥并写入NFC,Tag=" + tag);
+ // 1. 生成RSA私钥
+ final String privateKeyStr = mNfcRsaAuthTool.generateRsaPrivateKey();
+ if (privateKeyStr == null) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mTvNfcState.setText("❌ 私钥生成失败");
+ Toast.makeText(NfcRsaLoginActivity.this, "私钥生成失败,请重试", Toast.LENGTH_SHORT).show();
+ isPreparingInit = false;
+ mBtnOptKey.setEnabled(true);
+ }
+ });
+ LogUtils.e(TAG, "createWriteAndValidateKey: RSA私钥生成失败");
+ return;
+ }
+ LogUtils.d(TAG, "createWriteAndValidateKey: RSA私钥生成成功");
+
+ // 2. 写入NFC卡片
+ final boolean writeSuccess = mNfcRsaAuthTool.writePrivateKeyToNfc(tag, privateKeyStr);
+ if (!writeSuccess) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mTvNfcState.setText("❌ 私钥写入NFC失败");
+ Toast.makeText(NfcRsaLoginActivity.this, "私钥写入失败,请重试", Toast.LENGTH_SHORT).show();
+ isPreparingInit = false;
+ mBtnOptKey.setEnabled(true);
+ }
+ });
+ LogUtils.e(TAG, "createWriteAndValidateKey: 私钥写入NFC失败");
+ return;
+ }
+ LogUtils.d(TAG, "createWriteAndValidateKey: 私钥写入NFC成功");
+
+ // 3. 提取公钥并双重校验有效性
+ final String publicKeyStr = mNfcRsaAuthTool.extractPublicKeyFromPrivateKeyStr(privateKeyStr);
+ final boolean priValid = mNfcRsaAuthTool.validatePrivateKey(privateKeyStr);
+ final boolean pubValid = mNfcRsaAuthTool.validatePublicKey(privateKeyStr, publicKeyStr);
+
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ LogUtils.d(TAG, "createWriteAndValidateKey: 密钥校验完成,私钥有效=" + priValid + ",公钥有效=" + pubValid);
+ if (priValid && pubValid) {
+ mTvNfcState.setText("✅ 密钥初始化成功,已写入NFC");
+ mTvPrivateKey.setText("私钥内容:\n" + privateKeyStr);
+ mTvPublicKey.setText(publicKeyStr != null ? "公钥内容:\n" + publicKeyStr : "公钥内容:提取失败");
+ Toast.makeText(NfcRsaLoginActivity.this, "密钥创建写入成功,可贴近NFC保存本地", Toast.LENGTH_LONG).show();
+ } else {
+ mTvNfcState.setText("⚠️ 写入成功,但密钥校验失败");
+ Toast.makeText(NfcRsaLoginActivity.this, "写入成功但密钥无效,请重新操作", Toast.LENGTH_LONG).show();
+ }
+ mBtnOptKey.setText("初始化RSA密钥写入NFC");
+ mBtnOptKey.setEnabled(true);
+ isPreparingInit = false;
+ mTempPrivateKey = null;
+ }
+ });
+ }
+ }).start();
+ }
+
+ /**
+ * 保存有效私钥到应用data区,同时缓存到工具类内存属性
+ */
+ private void saveKeyToLocalAndCache() {
+ LogUtils.d(TAG, "saveKeyToLocalAndCache: 开始执行密钥本地保存+内存缓存");
+ if (mTempPrivateKey == null || mTempPrivateKey.isEmpty()) {
+ Toast.makeText(this, "无有效密钥可保存", Toast.LENGTH_SHORT).show();
+ LogUtils.w(TAG, "saveKeyToLocalAndCache: 临时有效私钥为空,保存失败");
+ return;
+ }
+ boolean saveSuccess = mNfcRsaAuthTool.savePrivateKeyToLocal(mTempPrivateKey);
+ if (saveSuccess) {
+ mTvNfcState.setText("✅ 密钥已保存到应用本地,登录完成");
+ mTvPrivateKey.setText("私钥内容:\n" + mNfcRsaAuthTool.getCachePrivateKeyStr());
+ mTvPublicKey.setText("公钥内容:\n" + mNfcRsaAuthTool.getCachePublicKeyStr());
+ mBtnOptKey.setEnabled(false);
+ Toast.makeText(this, "密钥保存成功,已缓存到内存", Toast.LENGTH_LONG).show();
+ LogUtils.d(TAG, "saveKeyToLocalAndCache: 密钥本地存储+工具类内存缓存成功");
+ } else {
+ Toast.makeText(this, "密钥保存到本地失败", Toast.LENGTH_SHORT).show();
+ LogUtils.e(TAG, "saveKeyToLocalAndCache: 密钥本地保存失败");
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (v.getId() == R.id.btn_create_write_key) {
+ LogUtils.d(TAG, "onClick: 点击功能按钮,当前按钮文本=" + mBtnOptKey.getText().toString());
+ if (!mNfcRsaAuthTool.isNfcAvailable()) {
+ Toast.makeText(this, "NFC不可用,无法执行操作", Toast.LENGTH_SHORT).show();
+ LogUtils.w(TAG, "onClick: NFC不可用,拒绝按钮操作");
+ return;
+ }
+
+ if (mBtnOptKey.getText().toString().contains("保存")) {
+ // 按钮为保存功能:直接保存本地+缓存
+ saveKeyToLocalAndCache();
+ } else {
+ // 按钮为初始化功能:进入准备状态,等待贴近NFC
+ isPreparingInit = true;
+ mTvNfcState.setText("请贴近NFC卡片,执行密钥写入...");
+ mBtnOptKey.setEnabled(false);
+ Toast.makeText(this, "请贴近NFC卡片完成密钥初始化", Toast.LENGTH_SHORT).show();
+ LogUtils.d(TAG, "onClick: 已进入密钥初始化准备状态,等待NFC贴近");
+ }
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (mNfcAdapter != null && mNfcRsaAuthTool.isNfcAvailable()) {
+ mNfcAdapter.enableForegroundDispatch(this, mNfcPendingIntent, null, null);
+ LogUtils.d(TAG, "onResume: NFC前台监听已启用");
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (mNfcAdapter != null) {
+ mNfcAdapter.disableForegroundDispatch(this);
+ LogUtils.d(TAG, "onPause: NFC前台监听已禁用");
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ LogUtils.d(TAG, "onNewIntent: 收到新NFC意图,分发处理");
+ handleNfcIntent(intent);
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/APPValidationDialog.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/APPValidationDialog.java
new file mode 100644
index 0000000..2e70ea8
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/APPValidationDialog.java
@@ -0,0 +1,186 @@
+package cc.winboll.studio.libappbase.dialogs;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.R;
+import cc.winboll.studio.libappbase.ToastUtils;
+import cc.winboll.studio.libappbase.utils.APPUtils;
+import cc.winboll.studio.libappbase.utils.ApkSignUtils;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2026-01-20 21:20:00
+ * @LastEditTime 2026-01-24 18:45:00
+ * @Describe 签名显示+正版校验对话框:展示应用签名字节位信息,调用网络接口完成正版合法性校验,实时返回校验结果
+ */
+public class APPValidationDialog extends Dialog {
+ // ===================================== 全局常量 =====================================
+ public static final String TAG = "AppValidationDialog";
+ // 签名字节位分组大小
+ private static final int BIT_GROUP_SIZE = 16;
+
+ // ===================================== 控件与上下文属性 =====================================
+ private Context mContext;
+ private EditText etSignFingerprint;
+ private TextView tvAuthResult;
+
+ // ===================================== 业务入参属性 =====================================
+ private String appName;
+ private String versionName;
+ private String clientSign;
+ private String clientHash;
+
+ // ===================================== 构造方法 =====================================
+ public APPValidationDialog(Context context, String appName, String versionName) {
+ super(context, R.style.DialogStyle);
+ this.mContext = context;
+ this.appName = appName;
+ this.versionName = versionName;
+ LogUtils.d(TAG, "AppValidationDialog: 构造方法初始化,入参-> projectName=" + appName + ", versionName=" + versionName);
+ }
+
+ // ===================================== 生命周期方法 =====================================
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ LogUtils.d(TAG, "onCreate: 对话框创建,开始初始化布局与业务逻辑");
+ setContentView(R.layout.dialog_sign_get);
+ setCancelable(true);
+ // 初始化应用签名与哈希
+ initSignAndHash();
+ // 初始化页面控件
+ initView();
+ // 执行签名展示与正版校验
+ doSignShowAndAuthCheck();
+ LogUtils.d(TAG, "onCreate: 对话框初始化流程执行完成");
+ }
+
+ // ===================================== 页面与数据初始化方法 =====================================
+ /**
+ * 初始化页面控件,绑定视图并设置基础属性
+ */
+ private void initView() {
+ LogUtils.d(TAG, "initView: 开始初始化页面控件");
+ etSignFingerprint = findViewById(R.id.et_sign_fingerprint);
+ tvAuthResult = findViewById(R.id.tv_auth_result);
+ // 签名显示框设为只读,方便用户复制
+ etSignFingerprint.setEnabled(false);
+ // 填充签名字节位信息
+ etSignFingerprint.setText(convertSignToBitArrayWithWrap(clientSign));
+ LogUtils.d(TAG, "initView: 控件初始化完成,已填充签名字节位信息");
+ }
+
+ /**
+ * 初始化应用签名与SHA256哈希,调用工具类获取与服务端对齐的参数
+ */
+ private void initSignAndHash() {
+ LogUtils.d(TAG, "initSignAndHash: 开始获取应用签名与SHA256哈希");
+ this.clientSign = ApkSignUtils.getApkSignAlignedWithServer(mContext);
+ this.clientHash = ApkSignUtils.getApkSHA256Hash(mContext);
+ LogUtils.d(TAG, "initSignAndHash: 签名与哈希获取完成-> clientSign=" + clientSign + ", clientHash=" + clientHash);
+ }
+
+ // ===================================== 核心业务方法 =====================================
+ /**
+ * 核心业务:展示签名字节位信息,发起网络正版校验请求
+ */
+ private void doSignShowAndAuthCheck() {
+ LogUtils.d(TAG, "doSignShowAndAuthCheck: 开始执行应用正版合法性校验");
+ // 校验签名与哈希非空,避免空参请求
+ if (clientSign == null || clientHash == null) {
+ String errorMsg = "应用签名或哈希获取失败,无法执行正版校验";
+ LogUtils.e(TAG, "doSignShowAndAuthCheck: " + errorMsg);
+ tvAuthResult.setTextColor(Color.RED);
+ tvAuthResult.setText(errorMsg);
+ ToastUtils.show(errorMsg);
+ return;
+ }
+ // 调用网络校验接口
+ new APPUtils().checkAPKValidation(
+ mContext,
+ appName,
+ versionName,
+ clientSign,
+ clientHash,
+ new APPUtils.CheckResultCallback() {
+ @Override
+ public void onResult(boolean isValid, String message) {
+ LogUtils.d(TAG, "checkAPKValidation: 校验结果返回-> isValid=" + isValid + ", message=" + message);
+ handleAuthResult(isValid, message);
+ }
+ }
+ );
+ }
+
+ /**
+ * 处理正版校验结果,更新UI并提示用户
+ * @param isValid 校验是否通过
+ * @param message 服务端返回提示信息
+ */
+ private void handleAuthResult(boolean isValid, String message) {
+ String showMessage;
+ if (isValid) {
+ showMessage = "< 这是正版的 WinBoLL 应用,请放心使用。 >";
+ tvAuthResult.setTextColor(Color.BLUE);
+ LogUtils.d(TAG, "handleAuthResult: 正版校验通过," + showMessage + ",服务端信息:" + message);
+ } else {
+ showMessage = "< 您使用的可能不是正版的 WinBoLL 应用。 >";
+ tvAuthResult.setTextColor(Color.RED);
+ LogUtils.e(TAG, "handleAuthResult: 正版校验失败," + showMessage + ",失败原因:" + message);
+ }
+ // 更新UI并弹提示
+ tvAuthResult.setText(showMessage);
+ ToastUtils.show(showMessage);
+ }
+
+ // ===================================== 工具方法 =====================================
+ /**
+ * 签名字符串转0/1比特数组格式:每2个bit加空格,每16位换行,提升可读性
+ * @param signStr 原始签名字符串
+ * @return 格式化后的比特数字符串,签名字符为空返回空串
+ */
+ private String convertSignToBitArrayWithWrap(String signStr) {
+ LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 开始格式化签名字符串为比特数组");
+ if (signStr == null || signStr.isEmpty()) {
+ LogUtils.w(TAG, "convertSignToBitArrayWithWrap: 原始签名字符串为空,返回空串");
+ return "";
+ }
+ // 字符转8位补零的二进制字符串
+ StringBuilder bitBuilder = new StringBuilder();
+ for (char c : signStr.toCharArray()) {
+ String bit8 = String.format("%8s", Integer.toBinaryString(c)).replace(' ', '0');
+ bitBuilder.append(bit8);
+ }
+ String fullBitStr = bitBuilder.toString();
+ LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 签名转二进制完成,总长度=" + fullBitStr.length() + "bit");
+
+ // 按16位分组,组内每2bit加空格,分组后换行
+ StringBuilder finalBuilder = new StringBuilder();
+ for (int i = 0; i < fullBitStr.length(); i += BIT_GROUP_SIZE) {
+ int end = Math.min(i + BIT_GROUP_SIZE, fullBitStr.length());
+ String group = fullBitStr.substring(i, end);
+ // 组内加空格
+ StringBuilder groupWithSpace = new StringBuilder();
+ for (int j = 0; j < group.length(); j++) {
+ groupWithSpace.append(group.charAt(j));
+ if ((j + 1) % 2 == 0 && j != group.length() - 1) {
+ groupWithSpace.append(" ");
+ }
+ }
+ finalBuilder.append(groupWithSpace);
+ // 最后一组不换行
+ if (end < fullBitStr.length()) {
+ finalBuilder.append("\n");
+ }
+ }
+ LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 签名比特数组格式化完成");
+ return finalBuilder.toString();
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/DebugHostDialog.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/DebugHostDialog.java
new file mode 100644
index 0000000..1141570
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/dialogs/DebugHostDialog.java
@@ -0,0 +1,99 @@
+package cc.winboll.studio.libappbase.dialogs;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Toast;
+import cc.winboll.studio.libappbase.GlobalApplication;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.R;
+import cc.winboll.studio.libappbase.ToastUtils;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/22 20:59
+ * @Describe WinBoLL服务器地址设置对话框(调试模式专用)
+ */
+public class DebugHostDialog extends Dialog implements View.OnClickListener {
+ public static final String TAG = "DebugHostDialog";
+
+ private Context mContext;
+ private EditText etHostInput;
+ private Button btnConfirm;
+ private Button btnCancel;
+
+ // 构造方法(适配默认样式)
+ public DebugHostDialog(Context context) {
+ super(context, R.style.DialogStyle);
+ this.mContext = context;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.dialog_winboll_host); // 绑定XML布局
+ setCancelable(true); // 点击外部可关闭
+ initView();
+ initData();
+ LogUtils.d(TAG, "DebugHostDialog 初始化完成");
+ }
+
+ // 初始化视图
+ private void initView() {
+ etHostInput = findViewById(R.id.et_host_input);
+ btnConfirm = findViewById(R.id.btn_confirm);
+ btnCancel = findViewById(R.id.btn_cancel);
+
+ // 绑定点击事件
+ btnConfirm.setOnClickListener(this);
+ btnCancel.setOnClickListener(this);
+ }
+
+ // 初始化数据(显示当前已保存的地址)
+ private void initData() {
+ String currentHost = GlobalApplication.getWinbollHost();
+ if (!TextUtils.isEmpty(currentHost)) {
+ etHostInput.setText(currentHost);
+ etHostInput.setSelection(currentHost.length()); // 光标定位到末尾
+ LogUtils.d(TAG, "当前已保存的服务器地址:" + currentHost);
+ }
+ }
+
+ @Override
+ public void onClick(View v) {
+ int id = v.getId();
+ if (id == R.id.btn_confirm) {
+ handleConfirm(); // 确认设置
+ } else if (id == R.id.btn_cancel) {
+ dismiss(); // 取消对话框
+ }
+ }
+
+ // 处理确认设置逻辑
+ private void handleConfirm() {
+ String inputHost = etHostInput.getText().toString().trim();
+ if (TextUtils.isEmpty(inputHost)) {
+ ToastUtils.show("服务器地址不能为空");
+ LogUtils.w(TAG, "设置失败:地址为空");
+ return;
+ }
+
+ // 简单校验URL格式(避免明显错误)
+ if (!inputHost.startsWith("http://") && !inputHost.startsWith("https://")) {
+ ToastUtils.show("地址需以http://或https://开头");
+ LogUtils.w(TAG, "设置失败:地址格式错误,input=" + inputHost);
+ return;
+ }
+
+ // 保存地址到SP+内存
+ GlobalApplication.setWinbollHost(inputHost);
+ ToastUtils.show("服务器地址设置成功");
+ LogUtils.d(TAG, "服务器地址设置成功:" + inputHost);
+ dismiss(); // 关闭对话框
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/models/APPInfo.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/models/APPInfo.java
new file mode 100644
index 0000000..df1dea9
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/models/APPInfo.java
@@ -0,0 +1,214 @@
+package cc.winboll.studio.libappbase.models;
+
+import cc.winboll.studio.libappbase.R;
+import cc.winboll.studio.libappbase.LogUtils;
+import java.io.Serializable;
+
+/**
+ * @Describe 应用信息实体类,存储应用核心配置信息,实现序列化接口
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/11 12:29:00
+ * @LastEditTime 2026/01/12 00:15:00
+ */
+public class APPInfo implements Serializable {
+ // 常量定义区
+ public static final String TAG = "APPInfo";
+
+ // 成员属性区(按功能归类排序,统一私有访问权限,Java7 兼容,注释清晰)
+ private String appName; // 应用名称
+ private int appIcon; // 应用图标资源ID
+ private String appDescription; // 应用描述文案
+ private String appGitName; // 应用Git仓库名称
+ private String appGitOwner; // 应用Git仓库拥有者账号
+ private String appGitAPPBranch; // 应用Git仓库对应分支
+ private String appGitAPPSubProjectFolder; // 应用Git仓库内子项目文件夹路径
+ private String appHomePage; // 应用官方主页地址
+ private String appAPKName; // 应用安装包名称
+ private String appAPKFolderName; // 应用安装包存储文件夹名称
+ private boolean isAddDebugTools; // 是否启用应用调试工具功能
+
+ // 构造方法区(按 参数从少到多 排序,逻辑清晰,便于调用选型)
+ /**
+ * 无参构造方法,默认初始化WinBoLL应用基础配置
+ */
+ public APPInfo() {
+ LogUtils.d(TAG, "APPInfo() 无参构造方法调用,执行默认配置初始化");
+ String szBranchName = "winboll";
+ this.appName = "WinBoLL";
+ this.appIcon = R.drawable.ic_winboll;
+ this.appDescription = "Hello, WinBoLl!";
+ this.appGitName = "WinBoLL";
+ this.appGitOwner = "Studio";
+ this.appGitAPPBranch = szBranchName;
+ this.appGitAPPSubProjectFolder = szBranchName;
+ this.appHomePage = "https://www.winboll.cc/apks/index.php?project=WinBoLL";
+ this.appAPKName = "WinBoLL";
+ this.appAPKFolderName = "WinBoLL";
+ this.isAddDebugTools = false;
+ LogUtils.d(TAG, "APPInfo() 无参构造初始化完成,默认应用名称:" + this.appName);
+ }
+
+ /**
+ * 多参构造方法(不含调试工具配置,默认关闭调试功能)
+ * @param appName 应用名称
+ * @param appIcon 应用图标资源ID
+ * @param appDescription 应用描述
+ * @param appGitName Git仓库名称
+ * @param appGitOwner Git仓库拥有者
+ * @param appGitAPPBranch Git仓库分支
+ * @param appGitAPPSubProjectFolder Git子项目文件夹
+ * @param appHomePage 应用主页
+ * @param appAPKName 应用包名
+ * @param appAPKFolderName 应用包存储文件夹名
+ */
+ public APPInfo(String appName, int appIcon, String appDescription, String appGitName, String appGitOwner,
+ String appGitAPPBranch, String appGitAPPSubProjectFolder, String appHomePage,
+ String appAPKName, String appAPKFolderName) {
+ LogUtils.d(TAG, "APPInfo(多参无调试) 构造调用,入参应用名:" + appName + " | Git仓库名:" + appGitName);
+ this.appName = appName;
+ this.appIcon = appIcon;
+ this.appDescription = appDescription;
+ this.appGitName = appGitName;
+ this.appGitOwner = appGitOwner;
+ this.appGitAPPBranch = appGitAPPBranch;
+ this.appGitAPPSubProjectFolder = appGitAPPSubProjectFolder;
+ this.appHomePage = appHomePage;
+ this.appAPKName = appAPKName;
+ this.appAPKFolderName = appAPKFolderName;
+ this.isAddDebugTools = false;
+ LogUtils.d(TAG, "APPInfo(多参无调试) 构造初始化完成");
+ }
+
+ /**
+ * 全参构造方法(包含调试工具配置,支持自定义调试开关)
+ * @param appName 应用名称
+ * @param appIcon 应用图标资源ID
+ * @param appDescription 应用描述
+ * @param appGitName Git仓库名称
+ * @param appGitOwner Git仓库拥有者
+ * @param appGitAPPBranch Git仓库分支
+ * @param appGitAPPSubProjectFolder Git子项目文件夹
+ * @param appHomePage 应用主页
+ * @param appAPKName 应用包名
+ * @param appAPKFolderName 应用包存储文件夹名
+ * @param isAddDebugTools 是否开启调试工具
+ */
+ public APPInfo(String appName, int appIcon, String appDescription, String appGitName, String appGitOwner,
+ String appGitAPPBranch, String appGitAPPSubProjectFolder, String appHomePage,
+ String appAPKName, String appAPKFolderName, boolean isAddDebugTools) {
+ LogUtils.d(TAG, "APPInfo(全参带调试) 构造调用,入参应用名:" + appName + " | 调试开关:" + isAddDebugTools);
+ this.appName = appName;
+ this.appIcon = appIcon;
+ this.appDescription = appDescription;
+ this.appGitName = appGitName;
+ this.appGitOwner = appGitOwner;
+ this.appGitAPPBranch = appGitAPPBranch;
+ this.appGitAPPSubProjectFolder = appGitAPPSubProjectFolder;
+ this.appHomePage = appHomePage;
+ this.appAPKName = appAPKName;
+ this.appAPKFolderName = appAPKFolderName;
+ this.isAddDebugTools = isAddDebugTools;
+ LogUtils.d(TAG, "APPInfo(全参带调试) 构造初始化完成");
+ }
+
+ // Getter/Setter 方法区(严格跟随成员属性定义顺序,易查找维护,仅Setter加调试日志)
+ public String getAppName() {
+ return appName;
+ }
+
+ public void setAppName(String appName) {
+ LogUtils.d(TAG, "setAppName() 调用,传入应用名称:" + appName);
+ this.appName = appName;
+ }
+
+ public int getAppIcon() {
+ return appIcon;
+ }
+
+ public void setAppIcon(int appIcon) {
+ LogUtils.d(TAG, "setAppIcon() 调用,传入图标资源ID:" + appIcon);
+ this.appIcon = appIcon;
+ }
+
+ public String getAppDescription() {
+ return appDescription;
+ }
+
+ public void setAppDescription(String appDescription) {
+ LogUtils.d(TAG, "setAppDescription() 调用,传入描述文案:" + appDescription);
+ this.appDescription = appDescription;
+ }
+
+ public String getAppGitName() {
+ return appGitName;
+ }
+
+ public void setAppGitName(String appGitName) {
+ LogUtils.d(TAG, "setAppGitName() 调用,传入Git仓库名:" + appGitName);
+ this.appGitName = appGitName;
+ }
+
+ public String getAppGitOwner() {
+ return appGitOwner;
+ }
+
+ public void setAppGitOwner(String appGitOwner) {
+ LogUtils.d(TAG, "setAppGitOwner() 调用,传入Git拥有者:" + appGitOwner);
+ this.appGitOwner = appGitOwner;
+ }
+
+ public String getAppGitAPPBranch() {
+ return appGitAPPBranch;
+ }
+
+ public void setAppGitAPPBranch(String appGitAPPBranch) {
+ LogUtils.d(TAG, "setAppGitAPPBranch() 调用,传入Git分支:" + appGitAPPBranch);
+ this.appGitAPPBranch = appGitAPPBranch;
+ }
+
+ public String getAppGitAPPSubProjectFolder() {
+ return appGitAPPSubProjectFolder;
+ }
+
+ public void setAppGitAPPSubProjectFolder(String appGitAPPSubProjectFolder) {
+ LogUtils.d(TAG, "setAppGitAPPSubProjectFolder() 调用,传入Git子项目文件夹:" + appGitAPPSubProjectFolder);
+ this.appGitAPPSubProjectFolder = appGitAPPSubProjectFolder;
+ }
+
+ public String getAppHomePage() {
+ return appHomePage;
+ }
+
+ public void setAppHomePage(String appHomePage) {
+ LogUtils.d(TAG, "setAppHomePage() 调用,传入应用主页地址:" + appHomePage);
+ this.appHomePage = appHomePage;
+ }
+
+ public String getAppAPKName() {
+ return appAPKName;
+ }
+
+ public void setAppAPKName(String appAPKName) {
+ LogUtils.d(TAG, "setAppAPKName() 调用,传入应用包名:" + appAPKName);
+ this.appAPKName = appAPKName;
+ }
+
+ public String getAppAPKFolderName() {
+ return appAPKFolderName;
+ }
+
+ public void setAppAPKFolderName(String appAPKFolderName) {
+ LogUtils.d(TAG, "setAppAPKFolderName() 调用,传入包存储文件夹名:" + appAPKFolderName);
+ this.appAPKFolderName = appAPKFolderName;
+ }
+
+ public boolean isAddDebugTools() {
+ return isAddDebugTools;
+ }
+
+ public void setIsAddDebugTools(boolean isAddDebugTools) {
+ LogUtils.d(TAG, "setIsAddDebugTools() 调用,传入调试开关状态:" + isAddDebugTools);
+ this.isAddDebugTools = isAddDebugTools;
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/models/SFTPAuthModel.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/models/SFTPAuthModel.java
new file mode 100644
index 0000000..38f7da8
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/models/SFTPAuthModel.java
@@ -0,0 +1,101 @@
+package cc.winboll.studio.libappbase.models;
+
+/**
+ * SFTP登录验证信息实体类
+ * 封装SFTP登录所需的所有配置信息:服务端地址、端口、账号密码、秘钥信息、编码
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/30 19:08:00
+ * @LastEditTime 2026/01/31 22:45:00
+ */
+public class SFTPAuthModel {
+ public static final String TAG = "SFTPAuthModel";
+
+ // SFTP服务器地址(必填,如192.168.1.100、sftp.xxx.com)
+ private String ftpServer;
+ // SFTP服务器端口(必填,默认22)
+ private int ftpPort = 22;
+ // SFTP登录用户名(匿名登录传null/空)
+ private String ftpUsername;
+ // SFTP登录密码(匿名登录传null/空)
+ private String ftpPassword;
+ // SFTP登录秘钥路径(秘钥登录时使用,本地绝对路径,如/sdcard/sftp/key.pem,账号密码登录传null/空)
+ private String ftpKeyPath;
+ // SFTP登录秘钥密码(秘钥有密码时填写,无密码传null/空)
+ private String ftpKeyPwd;
+ // SFTP编码(默认UTF-8,解决中文文件名乱码)
+ private String ftpCharset = "UTF-8";
+
+ // 空参构造(JavaBean规范)
+ public SFTPAuthModel() {
+ }
+
+ // 全参构造(快速初始化)
+ public SFTPAuthModel(String ftpServer, int ftpPort, String ftpUsername, String ftpPassword,
+ String ftpKeyPath, String ftpKeyPwd, String ftpCharset) {
+ this.ftpServer = ftpServer;
+ this.ftpPort = ftpPort;
+ this.ftpUsername = ftpUsername;
+ this.ftpPassword = ftpPassword;
+ this.ftpKeyPath = ftpKeyPath;
+ this.ftpKeyPwd = ftpKeyPwd;
+ this.ftpCharset = ftpCharset;
+ }
+
+ // ==================== Get/Set 方法 ====================
+ public String getFtpServer() {
+ return ftpServer;
+ }
+
+ public void setFtpServer(String ftpServer) {
+ this.ftpServer = ftpServer;
+ }
+
+ public int getFtpPort() {
+ return ftpPort;
+ }
+
+ public void setFtpPort(int ftpPort) {
+ this.ftpPort = ftpPort;
+ }
+
+ public String getFtpUsername() {
+ return ftpUsername;
+ }
+
+ public void setFtpUsername(String ftpUsername) {
+ this.ftpUsername = ftpUsername;
+ }
+
+ public String getFtpPassword() {
+ return ftpPassword;
+ }
+
+ public void setFtpPassword(String ftpPassword) {
+ this.ftpPassword = ftpPassword;
+ }
+
+ public String getFtpKeyPath() {
+ return ftpKeyPath;
+ }
+
+ public void setFtpKeyPath(String ftpKeyPath) {
+ this.ftpKeyPath = ftpKeyPath;
+ }
+
+ public String getFtpKeyPwd() {
+ return ftpKeyPwd;
+ }
+
+ public void setFtpKeyPwd(String ftpKeyPwd) {
+ this.ftpKeyPwd = ftpKeyPwd;
+ }
+
+ public String getFtpCharset() {
+ return ftpCharset;
+ }
+
+ public void setFtpCharset(String ftpCharset) {
+ this.ftpCharset = ftpCharset;
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/models/SignCheckResponse.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/models/SignCheckResponse.java
new file mode 100644
index 0000000..6ad724b
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/models/SignCheckResponse.java
@@ -0,0 +1,39 @@
+package cc.winboll.studio.libappbase.models;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/22 20:37
+ */
+// ==================== JSON响应模型(与后端返回字段完全匹配)====================
+public class SignCheckResponse {
+ private int code; // 根节点code(后端返回)
+ private String msg; // 根节点提示信息(后端返回,替换原message)
+ private DataBean data; // 根节点data对象(后端返回)
+
+ // 内部DataBean:对应后端返回的data字段内容
+ public static class DataBean {
+ private boolean valid; // 实际是否合法的标识(后端data.valid)
+ private String signature; // 加密后的签名
+ private String decryptedSign;// 解密后的原始签名
+ private long validTime; // 时间戳
+ }
+
+ // Getter/Setter(关键:获取data中的valid字段)
+ public boolean isValid() {
+ return data != null && data.valid; // 从data中获取valid值
+ }
+
+ public String getMessage() {
+ return msg; // 对应后端根节点的msg字段
+ }
+
+ // 其他必要的Getter/Setter(用于后续扩展)
+ public int getCode() {
+ return code;
+ }
+
+ public DataBean getData() {
+ return data;
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APKFileUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APKFileUtils.java
new file mode 100644
index 0000000..ae8dcda
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APKFileUtils.java
@@ -0,0 +1,306 @@
+package cc.winboll.studio.libappbase.utils;
+
+import cc.winboll.studio.libappbase.LogUtils;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * APK文件工具类(单例)- 生产级签名+哈希双校验版(修复Too short异常)
+ * 1. 稳定解析CERT.RSA原始字节,与客户端Signature.toByteArray()1:1对齐,解决X509解析异常
+ * 2. 支持SHA256文件哈希字节级唯一校验,签名+哈希双重验证
+ * 3. 入参包含:项目名/版本名/APK名/客户端签名/客户端哈希,适配生产级版本管理
+ * 4. APK路径规范:apks_root/项目名/debug/tag/APK文件(支持调试/正式环境)
+ * @Author ZhanGSKen
+ */
+public class APKFileUtils {
+ // 单例实例
+ private static volatile APKFileUtils sInstance;
+ // 配置项
+ private static final String CONFIG_SECTION = "APP";
+ private static final String KEY_APKS_FOLDER = "apks_folder_path";
+ // 算法常量(与客户端严格对齐)
+ private static final String SIGN_ALGORITHM = "SHA1"; // 签名摘要算法
+ private static final String HASH_ALGORITHM = "SHA-256"; // 文件哈希算法
+ // 签名文件(兼容大小写,适配所有打包工具)
+ private static final String CERT_RSA_UPPER = "META-INF/CERT.RSA";
+ private static final String CERT_RSA_LOWER = "META-INF/cert.rsa";
+ // APK根目录
+ private String apksRootPath;
+
+ private APKFileUtils() {}
+
+ /**
+ * 初始化工具类(需在应用启动时调用)
+ */
+ public static void init() {
+ if (sInstance == null) {
+ synchronized (APKFileUtils.class) {
+ if (sInstance == null) {
+ sInstance = new APKFileUtils();
+ //sInstance.loadConfig();
+ }
+ }
+ }
+ }
+
+ /**
+ * 获取单例实例
+ */
+ public static APKFileUtils getInstance() {
+ if (sInstance == null) {
+ LogUtils.e("APKFileUtils", "请先调用init()初始化工具类");
+ throw new IllegalStateException("APKFileUtils未初始化,请先调用init()");
+ }
+ return sInstance;
+ }
+
+ /**
+ * 加载配置文件中的APK根目录
+ */
+// private void loadConfig() {
+// try {
+// apksRootPath = IniConfigUtils.getConfigValue(CONFIG_SECTION, KEY_APKS_FOLDER, "").trim();
+// if (apksRootPath.isEmpty()) {
+// LogUtils.e("APKFileUtils", "配置项apks_folder_path为空,初始化失败");
+// return;
+// }
+// File rootDir = new File(apksRootPath);
+// if (!rootDir.exists() && !rootDir.mkdirs()) {
+// LogUtils.e("APKFileUtils", "APK根目录创建失败:" + apksRootPath);
+// apksRootPath = "";
+// return;
+// }
+// LogUtils.i("APKFileUtils", "APK根目录加载成功:" + apksRootPath);
+// } catch (Exception e) {
+// LogUtils.e("APKFileUtils", "加载APK根目录配置失败", e);
+// apksRootPath = "";
+// }
+// }
+
+ /**
+ * 对外暴露核心校验方法:签名 + SHA256文件哈希 双校验
+ * 入参包含:项目名/版本名/APK文件名/客户端签名Base64/客户端文件哈希
+ * APK路径规范:apksRootPath/项目名/版本名/APK文件
+ * @param projectName 项目名(非空)
+ * @param versionName 版本名(非空,如15.11.11)
+ * @param apkFileName APK文件名(非空,需以.apk结尾)
+ * @param clientSignBase64 客户端传入的签名Base64(非空)
+ * @param clientFileHash 客户端传入的APK文件SHA256哈希(小写/大写均可,非空)
+ * @return 校验通过返回true,否则false
+ */
+ public static boolean checkAPK(String projectName, String versionName, String apkFileName,
+ String clientSignBase64, String clientFileHash) {
+ return getInstance().doCheckAPK(projectName, versionName, apkFileName, clientSignBase64, clientFileHash);
+ }
+
+ /**
+ * 核心校验实现:严格按「哈希先验,签名后验」顺序,哈希不匹配直接返回
+ */
+ private boolean doCheckAPK(String projectName, String versionName, String apkFileName,
+ String clientSignBase64, String clientFileHash) {
+ // 1. 基础入参非空校验
+ if (isParamEmpty(projectName) || isParamEmpty(versionName) || isParamEmpty(apkFileName)
+ || isParamEmpty(clientSignBase64) || isParamEmpty(clientFileHash)) {
+ LogUtils.w("APKFileUtils", "基础参数不能为空:projectName/versionName/apkFileName/clientSignBase64/clientFileHash");
+ return false;
+ }
+ // 2. APK文件名格式校验
+ if (!apkFileName.endsWith(".apk")) {
+ LogUtils.w("APKFileUtils", "APK文件名格式错误,需以.apk结尾:" + apkFileName);
+ return false;
+ }
+ // 3. APK根目录校验
+ if (isParamEmpty(apksRootPath)) {
+ LogUtils.w("APKFileUtils", "APK根目录未配置,无法进行校验");
+ return false;
+ }
+ // 4. 拼接标准APK路径:根目录/项目名/debug/项目名_版本名.apk(调试环境,可切换tag)
+ String apkFullPath = String.format("%s/%s/debug/%s_%s.apk",
+ apksRootPath,
+ projectName,
+ projectName,
+ versionName);
+ //正式环境路径(注释保留,切换时解开即可)
+// String apkFullPath = String.format("%s/%s/tag/%s_%s.apk",
+// apksRootPath,
+// projectName,
+// projectName,
+// versionName);
+ LogUtils.d("APKFileUtils", String.format("apkFullPath : %s", apkFullPath));
+ File apkFile = new File(apkFullPath);
+ // 5. APK文件存在性校验
+ if (!apkFile.exists() || !apkFile.isFile()) {
+ LogUtils.w("APKFileUtils", "APK文件不存在或非文件类型:" + apkFullPath);
+ return false;
+ }
+
+ try {
+ // ===== 第一步:SHA256文件哈希校验(字节级唯一,优先级最高)=====
+ String serverFileHash = getAPKFileHash(apkFile);
+ if (isParamEmpty(serverFileHash)) {
+ LogUtils.w("APKFileUtils", "解析服务端APK文件哈希失败:" + apkFileName);
+ return false;
+ }
+ boolean isHashMatch = serverFileHash.equalsIgnoreCase(clientFileHash.trim());
+ LogUtils.d("APKFileUtils", "【哈希对比】服务端SHA256:" + serverFileHash);
+ LogUtils.d("APKFileUtils", "【哈希对比】客户端SHA256:" + clientFileHash.trim());
+ if (!isHashMatch) {
+ LogUtils.i("APKFileUtils", "【哈希对比结果】❌ 不匹配(字节级文件不一致)");
+ return false;
+ }
+ LogUtils.i("APKFileUtils", "【哈希对比结果】✅ 匹配(字节级文件完全一致)");
+
+ // ===== 第二步:签名校验(直接读取CERT.RSA原始字节,与客户端严格对齐)=====
+ String serverSignBase64 = getAPKSign(apkFile);
+ if (isParamEmpty(serverSignBase64)) {
+ LogUtils.w("APKFileUtils", "解析服务端APK签名失败:" + apkFileName);
+ return false;
+ }
+ boolean isSignMatch = serverSignBase64.equals(clientSignBase64.trim());
+ LogUtils.d("APKFileUtils", "【签名对比】服务端Base64:" + serverSignBase64);
+ LogUtils.d("APKFileUtils", "【签名对比】客户端Base64:" + clientSignBase64.trim());
+ if (!isSignMatch) {
+ LogUtils.i("APKFileUtils", "【签名对比结果】❌ 不匹配(签名不一致)");
+ return false;
+ }
+ LogUtils.i("APKFileUtils", "【签名对比结果】✅ 匹配(签名完全一致)");
+
+ // 所有校验通过
+ LogUtils.i("APKFileUtils", "APK双校验全部通过:项目名=" + projectName + ",版本名=" + versionName + ",文件名=" + apkFileName);
+ return true;
+ } catch (Exception e) {
+ LogUtils.e("APKFileUtils", "APK双校验异常", e);
+ return false;
+ }
+ }
+
+ /**
+ * 稳定解析APK签名:直接读取CERT.RSA原始字节,SHA1+Base64(与客户端1:1对齐)
+ * 解决X509证书解析的Too short异常,兼容所有APK(普通/加固/自定义打包)
+ * @param apkFile APK文件
+ * @return 签名Base64字符串,失败返回null
+ */
+ private String getAPKSign(File apkFile) {
+ JarFile jarFile = null;
+ InputStream certIs = null;
+ try {
+ jarFile = new JarFile(apkFile);
+ // 先找大写CERT.RSA,找不到再找小写,兼容所有打包工具
+ JarEntry certEntry = jarFile.getJarEntry(CERT_RSA_UPPER);
+ if (certEntry == null) {
+ certEntry = jarFile.getJarEntry(CERT_RSA_LOWER);
+ if (certEntry == null) {
+ LogUtils.w("APKFileUtils", "APK中未找到签名文件:META-INF/CERT.RSA/cert.rsa");
+ return null;
+ }
+ }
+ // 核心:直接读取CERT.RSA的原始字节流(不做证书解析,适配PKCS7签名块)
+ certIs = jarFile.getInputStream(certEntry);
+ byte[] sigRawBytes = readStreamToBytes(certIs);
+ if (sigRawBytes == null || sigRawBytes.length == 0) {
+ LogUtils.w("APKFileUtils", "读取CERT.RSA原始字节为空");
+ return null;
+ }
+ // 与客户端完全一致的处理流程:SHA1摘要 → Base64编码(去换行)
+ MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM);
+ byte[] signDigest = md.digest(sigRawBytes);
+ String signBase64 = Base64.getEncoder().encodeToString(signDigest)
+ .replaceAll("\\r", "").replaceAll("\\n", "");
+
+ LogUtils.d("APKFileUtils", "APK签名解析成功(Base64):" + signBase64);
+ return signBase64;
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e("APKFileUtils", "解析签名失败:" + SIGN_ALGORITHM + "算法不存在", e);
+ return null;
+ } catch (Exception e) {
+ LogUtils.e("APKFileUtils", "解析APK签名异常", e);
+ return null;
+ } finally {
+ // 强制关闭流资源,避免内存泄漏
+ try {
+ if (certIs != null) certIs.close();
+ if (jarFile != null) jarFile.close();
+ } catch (IOException e) {
+ LogUtils.e("APKFileUtils", "关闭签名文件流失败", e);
+ }
+ }
+ }
+
+ /**
+ * 解析APK文件的SHA256哈希(字节级唯一,任何字节修改都会改变)
+ * @param apkFile APK文件
+ * @return 小写64位SHA256哈希字符串,失败返回null
+ */
+ private String getAPKFileHash(File apkFile) {
+ FileInputStream fis = null;
+ try {
+ MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
+ fis = new FileInputStream(apkFile);
+ byte[] buffer = new byte[8192]; // 8K缓冲区,提升大APK读取效率
+ int len;
+ while ((len = fis.read(buffer)) != -1) {
+ md.update(buffer, 0, len);
+ }
+ // 哈希字节转小写16进制字符串(64位,官方标准格式)
+ byte[] hashBytes = md.digest();
+ StringBuilder sb = new StringBuilder();
+ for (byte b : hashBytes) {
+ sb.append(String.format("%02x", b));
+ }
+ String fileHash = sb.toString();
+ LogUtils.d("APKFileUtils", "APK文件SHA256哈希解析成功:" + fileHash);
+ return fileHash;
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e("APKFileUtils", "获取文件哈希失败:" + HASH_ALGORITHM + "算法不存在", e);
+ return null;
+ } catch (Exception e) {
+ LogUtils.e("APKFileUtils", "解析APK文件哈希异常", e);
+ return null;
+ } finally {
+ if (fis != null) {
+ try {
+ fis.close();
+ } catch (IOException e) {
+ LogUtils.e("APKFileUtils", "关闭APK文件流失败", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * 流转字节数组工具方法:稳定读取任意输入流,无截断/空指针问题
+ */
+ private byte[] readStreamToBytes(InputStream is) throws IOException {
+ if (is == null) {
+ LogUtils.w("APKFileUtils", "readStreamToBytes: 输入流为null");
+ return new byte[0];
+ }
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int len;
+ while ((len = is.read(buffer)) != -1) {
+ bos.write(buffer, 0, len);
+ }
+ byte[] result = bos.toByteArray();
+ // 按顺序关闭流
+ is.close();
+ bos.close();
+ return result;
+ }
+
+ /**
+ * 工具方法:判断参数是否为空(null/空字符串/全空格)
+ */
+ private boolean isParamEmpty(String param) {
+ return param == null || param.trim().isEmpty();
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APPUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APPUtils.java
new file mode 100644
index 0000000..b815497
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/APPUtils.java
@@ -0,0 +1,199 @@
+package cc.winboll.studio.libappbase.utils;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Base64;
+
+import cc.winboll.studio.libappbase.GlobalApplication;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.models.SignCheckResponse;
+
+import com.google.gson.Gson;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2026-01-20 19:17:00
+ * @LastEditTime 2026-01-24 17:58:00
+ * @Describe APPUtils 应用合法性校验工具类(OKHTTP网络校验版,兼容Java7)
+ * 对外传入签名/哈希值,拼接调试标识后发起网络校验,主线程返回校验结果
+ */
+public class APPUtils {
+ // ===================================== 全局常量/单例属性 =====================================
+ public static final String TAG = "APPUtils";
+ // 网络校验接口基础地址
+ private static final String CHECK_API_URI = "api/app-signatures-check";
+ // OKHTTP客户端单例(复用连接,避免资源浪费)
+ private static final OkHttpClient sOkHttpClient = new OkHttpClient();
+ // Gson解析单例(全局复用,提高解析效率)
+ private static final Gson sGson = new Gson();
+
+ // ===================================== 对外核心校验方法 =====================================
+ /**
+ * 检查应用合法性(外部传入签名+哈希,拼接调试标识发起网络校验)
+ * @param context 上下文,用于主线程回调
+ * @param projectName 项目名称(服务端区分项目标识)
+ * @param versionName 应用版本名(服务端版本校验)
+ * @param clientSign 外部计算的应用签名字符串(Base64)
+ * @param clientHash 外部计算的APK SHA256哈希字符串(小写16进制)
+ * @param callback 校验结果回调(主线程调用,返回是否合法+提示信息)
+ */
+ public void checkAPKValidation(Context context, String appName, String versionName,
+ String clientSign, String clientHash, final CheckResultCallback callback) {
+ // 方法调用+全量入参调试日志
+ LogUtils.d(TAG, "checkAPKValidation: 方法调用,入参-> appName=" + appName
+ + ", versionName=" + versionName + ", clientSign=" + clientSign + ", clientHash=" + clientHash);
+
+ // 1. 核心入参空值校验(快速失败)
+ if (context == null) {
+ LogUtils.w(TAG, "checkAPKValidation: 入参context为空,直接返回校验失败");
+ callCallbackOnMainThread(callback, false, "上下文对象不能为空");
+ return;
+ }
+ if (isStringEmpty(appName)) {
+ LogUtils.w(TAG, "checkAPKValidation: 入参projectName为空/空白,直接返回校验失败");
+ callCallbackOnMainThread(callback, false, "项目名称不能为空");
+ return;
+ }
+ if (isStringEmpty(versionName)) {
+ LogUtils.w(TAG, "checkAPKValidation: 入参versionName为空/空白,直接返回校验失败");
+ callCallbackOnMainThread(callback, false, "应用版本名不能为空");
+ return;
+ }
+ if (isStringEmpty(clientSign)) {
+ LogUtils.w(TAG, "checkAPKValidation: 入参clientSign为空/空白,直接返回校验失败");
+ callCallbackOnMainThread(callback, false, "应用签名字符串不能为空");
+ return;
+ }
+ if (isStringEmpty(clientHash)) {
+ LogUtils.w(TAG, "checkAPKValidation: 入参clientHash为空/空白,直接返回校验失败");
+ callCallbackOnMainThread(callback, false, "APK SHA256哈希字符串不能为空");
+ return;
+ }
+ LogUtils.d(TAG, "checkAPKValidation: 入参校验通过,开始处理网络请求");
+
+ // 2. 动态参数URL编码(避免特殊字符导致请求解析异常)
+ LogUtils.d(TAG, "checkAPKValidation: 开始对动态参数进行UTF-8 URL编码");
+ String encodeProjectName = urlEncode(appName);
+ String encodeVersionName = urlEncode(versionName);
+ String encodeClientSign = urlEncode(clientSign);
+ String encodeClientHash = urlEncode(clientHash);
+ String isDebug = String.valueOf(GlobalApplication.isDebugging());
+ LogUtils.d(TAG, "checkAPKValidation: 参数编码完成,debug标识=" + isDebug);
+
+ // 3. 构建完整网络校验请求URL
+ String requestUrl = String.format("%s?isDebug=%s&projectName=%s&versionName=%s&clientSign=%s&clientHash=%s",
+ GlobalApplication.getWinbollHost() + CHECK_API_URI,
+ isDebug,
+ encodeProjectName,
+ encodeVersionName,
+ encodeClientSign,
+ encodeClientHash);
+ LogUtils.d(TAG, "checkAPKValidation: 构建网络校验请求URL=" + requestUrl);
+
+ // 4. 发起OKHTTP异步GET请求(避免阻塞主线程)
+ LogUtils.d(TAG, "checkAPKValidation: 发起异步网络校验请求");
+ Request request = new Request.Builder().url(requestUrl).build();
+ sOkHttpClient.newCall(request).enqueue(new Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ String errorMsg = "网络校验请求失败:" + e.getMessage();
+ LogUtils.e(TAG, "checkAPKValidation: " + errorMsg, e);
+ callCallbackOnMainThread(callback, false, errorMsg);
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) throws IOException {
+ if (response.isSuccessful() && response.body() != null) {
+ // 响应成功,解析返回JSON
+ String responseJson = response.body().string();
+ LogUtils.d(TAG, "checkAPKValidation: 网络校验响应成功,JSON=" + responseJson);
+ SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class);
+ boolean isValid = checkResponse != null && checkResponse.isValid();
+ String msg = checkResponse != null ? checkResponse.getMessage() : "服务端响应解析失败";
+ LogUtils.d(TAG, "checkAPKValidation: 校验结果解析完成,isValid=" + isValid + ", 提示信息=" + msg);
+ callCallbackOnMainThread(callback, isValid, msg);
+ } else {
+ // 响应失败,返回状态码信息
+ String errorMsg = "网络校验响应失败,服务端状态码=" + response.code();
+ LogUtils.e(TAG, "checkAPKValidation: " + errorMsg);
+ callCallbackOnMainThread(callback, false, errorMsg);
+ }
+ }
+ });
+ }
+
+ // ===================================== 内部工具方法 =====================================
+ /**
+ * 字符串空值/空白校验工具
+ * @param str 待校验字符串
+ * @return true=空/空白,false=非空
+ */
+ private boolean isStringEmpty(String str) {
+ return str == null || str.trim().isEmpty();
+ }
+
+ /**
+ * URL编码工具(Java7适配,UTF-8编码,处理特殊字符)
+ * @param content 待编码内容
+ * @return 编码后的字符串,编码失败返回原内容
+ */
+ private String urlEncode(String content) {
+ try {
+ return URLEncoder.encode(content, "UTF-8");
+ } catch (Exception e) {
+ LogUtils.e(TAG, "urlEncode: 字符串编码失败,content=" + content, e);
+ return content;
+ }
+ }
+
+ /**
+ * 主线程执行回调(统一处理,避免外部线程切换)
+ * @param callback 回调接口
+ * @param isValid 是否合法
+ * @param message 提示信息
+ */
+ private void callCallbackOnMainThread(final CheckResultCallback callback,
+ final boolean isValid, final String message) {
+ if (callback == null) {
+ LogUtils.w(TAG, "callCallbackOnMainThread: 回调接口为null,无需执行");
+ return;
+ }
+ // 已在主线程直接执行,否则切换主线程
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ callback.onResult(isValid, message);
+ } else {
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ @Override
+ public void run() {
+ callback.onResult(isValid, message);
+ }
+ });
+ }
+ }
+
+ // ===================================== 校验结果回调接口 =====================================
+ /**
+ * 应用合法性校验结果回调接口(主线程调用)
+ */
+ public interface CheckResultCallback {
+ /**
+ * 校验结果回调方法
+ * @param isValid 是否合法(true=校验通过,false=校验失败)
+ * @param message 校验提示信息(失败时返回错误原因,成功时返回服务端提示)
+ */
+ void onResult(boolean isValid, String message);
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ApkSignUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ApkSignUtils.java
new file mode 100644
index 0000000..b86aa9e
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ApkSignUtils.java
@@ -0,0 +1,272 @@
+package cc.winboll.studio.libappbase.utils;
+
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+import android.util.Base64;
+
+import cc.winboll.studio.libappbase.LogUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Enumeration;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2026-01-24 10:00:00
+ * @LastEditTime 2026-01-24 22:00:00
+ * @Describe 客户端签名工具类:与服务端APKFileUtils签名/哈希校验逻辑严格对齐,纯Java7实现;兼容MT重签名(遍历META-INF所有RSA文件),增加PackageManager兜底方案
+ */
+public class ApkSignUtils {
+ // ===================================== 全局常量定义 =====================================
+ private static final String TAG = "ApkSignUtils";
+ // 加密算法常量
+ private static final String ALGORITHM_SHA1 = "SHA1";
+ private static final String ALGORITHM_SHA256 = "SHA-256";
+ // 缓冲区大小常量(按业务场景区分)
+ private static final int BUFFER_4K = 4096;
+ private static final int BUFFER_8K = 8192;
+ // 签名文件目录与后缀
+ private static final String META_INF_DIR = "META-INF/";
+ private static final String RSA_SUFFIX_UPPER = ".RSA";
+ private static final String RSA_SUFFIX_LOWER = ".rsa";
+
+ // ===================================== 对外核心方法 =====================================
+ /**
+ * 获取与服务端对齐的签名Base64串(兼容MT重签名)
+ * 优先逻辑:遍历APK内META-INF所有.RSA文件 → 读取第一个有效文件原始字节 → SHA1摘要 → Base64.NO_WRAP
+ * 兜底逻辑:PackageManager获取系统解析的签名 → SHA1摘要 → Base64.NO_WRAP
+ * @param context 上下文,用于获取当前应用APK路径/包信息
+ * @return 签名Base64字符串,任意步骤失败返回null
+ */
+ public static String getApkSignAlignedWithServer(Context context) {
+ LogUtils.d(TAG, "getApkSignAlignedWithServer: 方法调用,开始执行服务端对齐签名计算(兼容MT重签名)");
+ // 入参空值快速校验
+ if (context == null) {
+ LogUtils.w(TAG, "getApkSignAlignedWithServer: 入参context为null,直接返回null");
+ return null;
+ }
+
+ // 方案1:优先读取APK内META-INF目录下所有RSA文件(兼容MT重签名任意命名)
+ String signBase64 = getSignFromApkRsaFile(context);
+ if (signBase64 != null) {
+ LogUtils.d(TAG, "getApkSignAlignedWithServer: 方案1成功(APK内读取RSA文件),返回签名Base64");
+ return signBase64;
+ }
+
+ // 方案2:兜底 - PackageManager获取系统解析的应用签名(避免APK文件读取失败)
+ signBase64 = getSignFromPackageManager(context);
+ if (signBase64 != null) {
+ LogUtils.d(TAG, "getApkSignAlignedWithServer: 方案2成功(PackageManager兜底),返回签名Base64");
+ return signBase64;
+ }
+
+ // 所有方案失败
+ LogUtils.e(TAG, "getApkSignAlignedWithServer: 所有签名获取方案均失败");
+ return null;
+ }
+
+ /**
+ * 获取当前运行APK的SHA256哈希值(兼容重签名APK)
+ * 逻辑:读取APK完整文件字节流 → SHA256摘要 → 转小写64位16进制字符串,服务端同款校验逻辑
+ * @param context 上下文,用于获取当前应用APK的真实安装路径
+ * @return SHA256小写16进制字符串,任意步骤失败返回null
+ */
+ public static String getApkSHA256Hash(Context context) {
+ LogUtils.d(TAG, "getApkSHA256Hash: 方法调用,开始执行APK文件SHA256哈希计算");
+ // 入参空值快速校验
+ if (context == null) {
+ LogUtils.w(TAG, "getApkSHA256Hash: 入参context为null,直接返回null");
+ return null;
+ }
+
+ JarFile jarFile = null;
+ FileInputStream fis = null;
+ try {
+ // 1. 获取当前应用APK真实路径
+ ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
+ String apkPath = appInfo.sourceDir;
+ LogUtils.d(TAG, "getApkSHA256Hash: 成功获取APK路径,path=" + apkPath);
+ if (apkPath == null || apkPath.trim().isEmpty()) {
+ LogUtils.e(TAG, "getApkSHA256Hash: 获取到的APK路径为空,无法读取文件计算哈希");
+ return null;
+ }
+
+ // 2. 读取APK文件并计算SHA256哈希(完善流关闭)
+ File apkFile = new File(apkPath);
+ MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA256);
+ fis = new FileInputStream(apkFile);
+ byte[] buffer = new byte[BUFFER_8K];
+ int readLen;
+ while ((readLen = fis.read(buffer)) != -1) {
+ md.update(buffer, 0, readLen);
+ }
+ LogUtils.d(TAG, "getApkSHA256Hash: APK文件读取完成,开始转换哈希结果");
+
+ // 3. 哈希字节数组转小写64位16进制字符串
+ byte[] hashBytes = md.digest();
+ StringBuilder sb = new StringBuilder();
+ for (byte b : hashBytes) {
+ sb.append(String.format("%02x", b));
+ }
+ String sha256Hash = sb.toString();
+ LogUtils.d(TAG, "getApkSHA256Hash: APK SHA256哈希计算完成,成功返回结果");
+ return sha256Hash;
+
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e(TAG, "getApkSHA256Hash: 获取SHA-256算法实例失败", e);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "getApkSHA256Hash: 计算APK SHA256哈希发生未知异常", e);
+ } finally {
+ // 强制关闭流,避免重签名APK解析的流泄漏
+ try {
+ if (fis != null) fis.close();
+ if (jarFile != null) jarFile.close();
+ } catch (IOException e) {
+ LogUtils.e(TAG, "getApkSHA256Hash: 关闭流资源异常", e);
+ }
+ }
+ return null;
+ }
+
+ // ===================================== 内部核心工具方法(兼容重签名) =====================================
+ /**
+ * 方案1:遍历APK内META-INF所有.RSA/.rsa文件,读取第一个有效文件计算签名
+ * @param context 上下文
+ * @return 签名Base64,失败返回null
+ */
+ private static String getSignFromApkRsaFile(Context context) {
+ JarFile jarFile = null;
+ InputStream is = null;
+ try {
+ // 获取APK路径
+ ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
+ String apkPath = appInfo.sourceDir;
+ if (apkPath == null || apkPath.trim().isEmpty()) {
+ LogUtils.w(TAG, "getSignFromApkRsaFile: APK路径为空,跳过该方案");
+ return null;
+ }
+
+ // 打开APK的JarFile
+ jarFile = new JarFile(apkPath);
+ Enumeration entries = jarFile.entries();
+ JarEntry targetRsaEntry = null;
+
+ // 遍历所有条目,找到META-INF下第一个.RSA/.rsa文件
+ while (entries.hasMoreElements()) {
+ JarEntry entry = entries.nextElement();
+ String entryName = entry.getName();
+ // 过滤:META-INF目录下 + 以.RSA/.rsa结尾 + 非目录
+ if (entryName.startsWith(META_INF_DIR) && !entry.isDirectory()
+ && (entryName.endsWith(RSA_SUFFIX_UPPER) || entryName.endsWith(RSA_SUFFIX_LOWER))) {
+ targetRsaEntry = entry;
+ LogUtils.d(TAG, "getSignFromApkRsaFile: 找到有效签名文件,name=" + entryName);
+ break; // 取第一个有效RSA文件即可
+ }
+ }
+
+ // 未找到任何RSA文件
+ if (targetRsaEntry == null) {
+ LogUtils.w(TAG, "getSignFromApkRsaFile: 未在META-INF找到任何.RSA/.rsa签名文件");
+ return null;
+ }
+
+ // 读取RSA文件原始字节
+ is = jarFile.getInputStream(targetRsaEntry);
+ byte[] certRawBytes = readStreamToBytes(is);
+ if (certRawBytes == null || certRawBytes.length == 0) {
+ LogUtils.w(TAG, "getSignFromApkRsaFile: 读取RSA文件字节为空");
+ return null;
+ }
+
+ // 计算SHA1+Base64
+ MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA1);
+ byte[] signDigest = md.digest(certRawBytes);
+ return Base64.encodeToString(signDigest, Base64.NO_WRAP);
+
+ } catch (Exception e) {
+ LogUtils.e(TAG, "getSignFromApkRsaFile: 从APK内读取RSA文件失败", e);
+ return null;
+ } finally {
+ // 强制关闭所有流
+ try {
+ if (is != null) is.close();
+ if (jarFile != null) jarFile.close();
+ } catch (IOException e) {
+ LogUtils.e(TAG, "getSignFromApkRsaFile: 关闭流资源异常", e);
+ }
+ }
+ }
+
+ /**
+ * 方案2:兜底 - 通过PackageManager获取系统解析的应用签名
+ * 避免APK文件读取失败(如权限、解析问题),兼容所有重签名场景
+ * @param context 上下文
+ * @return 签名Base64,失败返回null
+ */
+ private static String getSignFromPackageManager(Context context) {
+ try {
+ // 获取当前应用包信息(包含签名)
+ PackageInfo packageInfo = context.getPackageManager()
+ .getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
+ if (packageInfo == null || packageInfo.signatures == null || packageInfo.signatures.length == 0) {
+ LogUtils.w(TAG, "getSignFromPackageManager: 未获取到应用签名信息");
+ return null;
+ }
+
+ // 取第一个签名(重签名后一般只有一个签名)
+ Signature signature = packageInfo.signatures[0];
+ byte[] signBytes = signature.toByteArray();
+
+ // 计算SHA1+Base64,与服务端逻辑对齐
+ MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA1);
+ byte[] signDigest = md.digest(signBytes);
+ return Base64.encodeToString(signDigest, Base64.NO_WRAP);
+
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.e(TAG, "getSignFromPackageManager: 包名未找到,无法获取签名", e);
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e(TAG, "getSignFromPackageManager: 获取SHA1算法实例失败", e);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "getSignFromPackageManager: PackageManager获取签名失败", e);
+ }
+ return null;
+ }
+
+ /**
+ * 输入流转字节数组,通用工具方法(完善try-finally)
+ * 4K缓冲区,适配小文件读取(如RSA签名文件),保证流资源正常关闭
+ * @param is 待读取的输入流
+ * @return 转换后的字节数组,流为null/读取失败返回空字节数组
+ * @throws IOException 流读取相关异常向上抛出
+ */
+ private static byte[] readStreamToBytes(InputStream is) throws IOException {
+ if (is == null) {
+ LogUtils.w(TAG, "readStreamToBytes: 入参输入流为null,返回空字节数组");
+ return new byte[0];
+ }
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[BUFFER_4K];
+ int readLen;
+ try {
+ while ((readLen = is.read(buffer)) != -1) {
+ bos.write(buffer, 0, readLen);
+ }
+ return bos.toByteArray();
+ } finally {
+ // 强制关闭所有流
+ is.close();
+ bos.close();
+ }
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/BackupUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/BackupUtils.java
new file mode 100644
index 0000000..dcbe80a
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/BackupUtils.java
@@ -0,0 +1,305 @@
+package cc.winboll.studio.libappbase.utils;
+
+import android.content.Context;
+import android.os.Environment;
+import android.text.TextUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.models.SFTPAuthModel;
+
+/**
+ * 文件备份工具类(单例模式)
+ * 区分应用Data目录/应用专属外部文件目录双Map管理备份文件路径
+ * 核心功能:文件添加/移除 + ZIP打包(分data/sdcard目录) + SFTP分步式上传(登录→传输→登出)
+ * 依赖:FTPUtils(单例)、SFTPAuthModel(外部实体类)、Android上下文
+ * 兼容:Java7、Android 6.0+,无第三方依赖(ZIP为原生实现),免动态读写权限
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/30 20:18:00
+ * @LastEditTime 2026/02/01 02:05:00
+ */
+public class BackupUtils {
+ public static final String TAG = "BackupUtils";
+ // ZIP内部分级目录常量(统一维护,便于修改)
+ private static final String ZIP_DIR_DATA = "data/";
+ private static final String ZIP_DIR_SDCARD = "sdcard/";
+
+ // 单例实例(双重校验锁,volatile保证可见性,线程安全)
+ private static volatile BackupUtils sInstance;
+
+ // 双Map分目录管理:key=文件唯一标识,value=对应目录下的相对路径
+ private final Map mDataDirFileMap; // 基础根目录:应用私有Data目录(/data/data/[包名]/files)
+ private final Map mSdcardFileMap; // 基础根目录:应用专属外部文件目录(/storage/emulated/0/Android/data/[包名]/files)
+
+ // 全局上下文(持有Application上下文,避免Activity内存泄漏)
+ private Context mAppContext;
+ // SFTP认证配置(直接引用外部实体类,无内部封装)
+ private SFTPAuthModel mFtpAuthModel;
+ // SFTP服务器指定上传目录(独立参数传入,标准化后作为成员变量)
+ private String mFtpTargetDir;
+ // 应用专属外部文件目录(SDCard Map的基础根目录,初始化时赋值,避免重复创建)
+ private File mAppExternalFilesDir;
+
+ // 私有构造器:新增双Map入参,空值则使用内部默认初始化,非空则用入参初始化
+ private BackupUtils(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir,
+ Map dataDirFileMap, Map sdcardFileMap) {
+ this.mAppContext = context.getApplicationContext();
+ this.mFtpAuthModel = ftpAuthModel;
+ // 初始化SDCard Map的基础根目录:应用专属外部文件目录(/storage/emulated/0/Android/data/[包名]/files)
+ this.mAppExternalFilesDir = mAppContext.getExternalFilesDir(null);
+ // 标准化SFTP上传目录:空则默认/,非空则补全结尾斜杠
+ this.mFtpTargetDir = TextUtils.isEmpty(ftpTargetDir) ? "/" : (ftpTargetDir.endsWith("/") ? ftpTargetDir : ftpTargetDir + "/");
+
+ // 核心修改:入参Map非空且非空集合时,使用入参初始化;否则内部new HashMap()
+ this.mDataDirFileMap = (dataDirFileMap != null && !dataDirFileMap.isEmpty())
+ ? new HashMap(dataDirFileMap)
+ : new HashMap();
+ this.mSdcardFileMap = (sdcardFileMap != null && !sdcardFileMap.isEmpty())
+ ? new HashMap(sdcardFileMap)
+ : new HashMap();
+
+ LogUtils.d(TAG, "BackupUtils初始化完成 → SFTP服务器:" + ftpAuthModel.getFtpServer() + ":" + ftpAuthModel.getFtpPort() + " | 上传目录:" + mFtpTargetDir);
+ LogUtils.d(TAG, "SDCard Map基础根目录:" + (mAppExternalFilesDir == null ? "获取失败" : mAppExternalFilesDir.getAbsolutePath()));
+ LogUtils.d(TAG, "初始化后DataMap大小:" + mDataDirFileMap.size() + " | SdcardMap大小:" + mSdcardFileMap.size());
+ }
+
+ /**
+ * 单例初始化方法(必须先调用,否则getInstance()会抛异常)
+ * 新增双Map入参,支持外部初始化待备份文件列表
+ * @param context 上下文(推荐传Application,避免内存泄漏)
+ * @param ftpAuthModel 外部SFTP认证实体类(含服务器/账号/端口等)
+ * @param ftpTargetDir SFTP服务器指定上传目录(如/backup,自动补全斜杠)
+ * @param dataDirFileMap 外部传入的Data目录文件Map,null/空则内部默认初始化
+ * @param sdcardFileMap 外部传入的SDCard目录文件Map,null/空则内部默认初始化
+ * @return BackupUtils单例实例
+ */
+ public static BackupUtils getInstance(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir,
+ Map dataDirFileMap, Map sdcardFileMap) {
+ if (sInstance == null) {
+ synchronized (BackupUtils.class) {
+ if (sInstance == null) {
+ // 前置强校验:避免空参数导致后续空指针
+ if (context == null) {
+ throw new IllegalArgumentException("初始化失败:Context 不能为空");
+ }
+ if (ftpAuthModel == null || TextUtils.isEmpty(ftpAuthModel.getFtpServer())) {
+ throw new IllegalArgumentException("初始化失败:SFTPAuthModel/ftpServer 不能为空");
+ }
+ // 透传新增的双Map入参至构造器
+ sInstance = new BackupUtils(context, ftpAuthModel, ftpTargetDir, dataDirFileMap, sdcardFileMap);
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ /**
+ * 重载默认初始化方法:兼容原有调用逻辑,无需传入Map,内部默认初始化
+ * 避免修改后影响原有代码调用
+ */
+ public static BackupUtils getInstance(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir) {
+ return getInstance(context, ftpAuthModel, ftpTargetDir, null, null);
+ }
+
+ /**
+ * 获取单例实例(需先调用带参getInstance初始化)
+ * @return BackupUtils单例实例
+ */
+ public static BackupUtils getInstance() {
+ if (sInstance == null) {
+ throw new IllegalStateException("BackupUtils未初始化,请先调用getInstance(Context, SFTPAuthModel, String[, Map, Map])");
+ }
+ return sInstance;
+ }
+
+ // ====================================== 以下原有方法均未修改 ======================================
+ public void addDataDirFile(String key, String relativePath) {
+ if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath)) {
+ mDataDirFileMap.put(key, relativePath);
+ LogUtils.d(TAG, "添加Data目录文件:" + key + " → " + relativePath);
+ }
+ }
+
+ public void removeDataDirFile(String key) {
+ if (!TextUtils.isEmpty(key) && mDataDirFileMap.containsKey(key)) {
+ mDataDirFileMap.remove(key);
+ LogUtils.d(TAG, "移除Data目录文件:" + key);
+ }
+ }
+
+ public String getDataDirFile(String key) {
+ return mDataDirFileMap.get(key);
+ }
+
+ public Map getAllDataDirFiles() {
+ return new HashMap<>(mDataDirFileMap);
+ }
+
+ public void clearDataDirFiles() {
+ mDataDirFileMap.clear();
+ LogUtils.d(TAG, "清空Data目录所有备份文件");
+ }
+
+ public void addSdcardFile(String key, String relativePath) {
+ if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath) && mAppExternalFilesDir != null) {
+ mSdcardFileMap.put(key, relativePath);
+ LogUtils.d(TAG, "添加外部文件目录文件:" + key + " → " + relativePath);
+ }
+ }
+
+ public void removeSdcardFile(String key) {
+ if (!TextUtils.isEmpty(key) && mSdcardFileMap.containsKey(key)) {
+ mSdcardFileMap.remove(key);
+ LogUtils.d(TAG, "移除外部文件目录文件:" + key);
+ }
+ }
+
+ public String getSdcardFile(String key) {
+ return mSdcardFileMap.get(key);
+ }
+
+ public Map getAllSdcardFiles() {
+ return new HashMap<>(mSdcardFileMap);
+ }
+
+ public void clearSdcardFiles() {
+ mSdcardFileMap.clear();
+ LogUtils.d(TAG, "清空外部文件目录所有备份文件");
+ }
+
+ public boolean packAndUploadByFtp() {
+ if (mDataDirFileMap.isEmpty() && mSdcardFileMap.isEmpty()) {
+ LogUtils.e(TAG, "SFTP上传失败:无待备份文件(DataDir+外部文件目录均为空)");
+ return false;
+ }
+ if (mAppExternalFilesDir == null) {
+ LogUtils.e(TAG, "SFTP上传失败:应用专属外部文件目录获取失败,无法访问文件");
+ return false;
+ }
+
+ String zipFileName = UUID.randomUUID().toString().replace("-", "")
+ + "-" + System.currentTimeMillis() + ".zip";
+ File tempZipFile = new File(mAppContext.getExternalCacheDir(), zipFileName);
+ String remoteFtpFilePath = mFtpTargetDir + zipFileName;
+
+ FTPUtils ftpUtils = FTPUtils.getInstance();
+ boolean isUploadSuccess = false;
+
+ try {
+ LogUtils.d(TAG, "开始SFTP登录:" + mFtpAuthModel.getFtpServer() + ":" + mFtpAuthModel.getFtpPort());
+ boolean isFtpLogin = ftpUtils.login(mFtpAuthModel);
+ if (!isFtpLogin) {
+ LogUtils.e(TAG, "SFTP上传失败:SFTP登录失败(账号/密码/服务器/端口错误)");
+ return false;
+ }
+ LogUtils.i(TAG, "SFTP登录成功,准备打包文件:" + zipFileName);
+
+ LogUtils.d(TAG, "开始本地ZIP打包(分data/sdcard目录),临时文件路径:" + tempZipFile.getAbsolutePath());
+ boolean isPackSuccess = packFilesToZip(tempZipFile);
+ if (!isPackSuccess || !tempZipFile.exists() || tempZipFile.length() == 0) {
+ LogUtils.e(TAG, "SFTP上传失败:ZIP打包失败(文件不存在/空文件)");
+ return false;
+ }
+ LogUtils.i(TAG, "ZIP打包成功,文件大小:" + tempZipFile.length() / 1024 + "KB");
+
+ LogUtils.d(TAG, "开始SFTP上传:本地→SFTP" + remoteFtpFilePath);
+ isUploadSuccess = ftpUtils.uploadFile(tempZipFile.getAbsolutePath(), remoteFtpFilePath);
+ if (isUploadSuccess) {
+ LogUtils.i(TAG, "SFTP上传全流程成功:" + remoteFtpFilePath);
+ } else {
+ LogUtils.e(TAG, "SFTP上传失败:文件传输到服务器失败(响应码异常/权限不足)");
+ }
+
+ } catch (Exception e) {
+ LogUtils.e(TAG, "SFTP上传异常:" + e.getMessage(), e);
+ isUploadSuccess = false;
+ } finally {
+ if (ftpUtils.isConnected()) {
+ ftpUtils.logout();
+ }
+ ftpUtils.disconnect();
+ if (tempZipFile.exists()) {
+ boolean isDelete = tempZipFile.delete();
+ LogUtils.d(TAG, "本地临时ZIP文件删除:" + (isDelete ? "成功" : "失败"));
+ }
+ System.gc();
+ }
+
+ return isUploadSuccess;
+ }
+
+ private boolean packFilesToZip(File zipFile) {
+ ZipOutputStream zos = null;
+ try {
+ zos = new ZipOutputStream(new FileOutputStream(zipFile), Charset.forName("UTF-8"));
+ zos.setLevel(ZipOutputStream.DEFLATED);
+
+ if (!mDataDirFileMap.isEmpty()) {
+ packDirFilesToZip(zos, mDataDirFileMap, mAppContext.getFilesDir(), ZIP_DIR_DATA);
+ LogUtils.d(TAG, "Data目录文件已打包到ZIP→" + ZIP_DIR_DATA + "子目录");
+ }
+ if (!mSdcardFileMap.isEmpty() && mAppExternalFilesDir != null) {
+ packDirFilesToZip(zos, mSdcardFileMap, mAppExternalFilesDir, ZIP_DIR_SDCARD);
+ LogUtils.d(TAG, "应用专属外部文件目录文件已打包到ZIP→" + ZIP_DIR_SDCARD + "子目录");
+ }
+
+ zos.flush();
+ return true;
+ } catch (IOException e) {
+ LogUtils.e(TAG, "ZIP打包IO异常:" + e.getMessage(), e);
+ return false;
+ } finally {
+ if (zos != null) {
+ try {
+ zos.close();
+ } catch (IOException e) {
+ LogUtils.e(TAG, "关闭ZIP流异常:" + e.getMessage(), e);
+ }
+ }
+ }
+ }
+
+ private void packDirFilesToZip(ZipOutputStream zos, Map fileMap, File baseDir, String zipSubDir) {
+ for (Map.Entry entry : fileMap.entrySet()) {
+ String relativePath = entry.getValue();
+ if (TextUtils.isEmpty(relativePath)) {
+ continue;
+ }
+ File localFile = new File(baseDir, relativePath);
+ if (!localFile.exists() || !localFile.isFile()) {
+ LogUtils.w(TAG, "跳过无效文件:" + localFile.getAbsolutePath());
+ continue;
+ }
+ String zipInnerPath = zipSubDir + relativePath;
+ try {
+ addSingleFileToZip(zos, localFile, zipInnerPath);
+ } catch (IOException e) {
+ LogUtils.e(TAG, "打包单个文件失败:" + zipInnerPath, e);
+ }
+ }
+ }
+
+ private void addSingleFileToZip(ZipOutputStream zos, File localFile, String zipInnerPath) throws IOException {
+ ZipEntry zipEntry = new ZipEntry(zipInnerPath);
+ zos.putNextEntry(zipEntry);
+ FileInputStream fis = new FileInputStream(localFile);
+ byte[] buffer = new byte[4096];
+ int len;
+ while ((len = fis.read(buffer)) != -1) {
+ zos.write(buffer, 0, len);
+ }
+ fis.close();
+ zos.closeEntry();
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/CrashHandleNotifyUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/CrashHandleNotifyUtils.java
new file mode 100644
index 0000000..06f9d73
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/CrashHandleNotifyUtils.java
@@ -0,0 +1,443 @@
+package cc.winboll.studio.libappbase.utils;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.text.TextUtils;
+
+import cc.winboll.studio.libappbase.CrashHandler;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.R;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStreamReader;
+
+/**
+ * 应用崩溃处理通知实用工具集(类库兼容版)
+ * 核心功能:作为独立类库使用,发送崩溃通知,点击跳转宿主应用的 GlobalCrashActivity 并传递日志
+ * 适配说明:移除固定包名依赖,通过外部传入宿主包名,支持任意应用集成使用
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2025/11/29 21:12:00
+ * @EditTime 2026/05/11 21:55:00
+ */
+public class CrashHandleNotifyUtils {
+
+ // ====================== 常量定义 ======================
+ public static final String TAG = "CrashHandleNotifyUtils";
+ /** 通知渠道ID(Android 8.0+ 必须) */
+ private static final String CRASH_NOTIFY_CHANNEL_ID = "crash_notify_channel";
+ /** 通知渠道名称(用户可见) */
+ private static final String CRASH_NOTIFY_CHANNEL_NAME = "应用崩溃通知";
+ /** 通知ID(唯一) */
+ public static final int CRASH_NOTIFY_ID = 0x001;
+ /** Android 12 对应 API 版本号(31) */
+ private static final int API_LEVEL_ANDROID_12 = 31;
+ /** PendingIntent.FLAG_IMMUTABLE 常量值(API 31+) */
+ private static final int FLAG_IMMUTABLE = 0x00000040;
+ /** 通知摘要最大长度 */
+ private static final int SUMMARY_MAX_LENGTH = 200;
+ /** 分享日志请求码 */
+ private static final int REQUEST_CODE_SHARE_LOG = 0x002;
+ /** 缓存崩溃日志子目录 */
+ private static final String CRASH_LOG_CACHE_SUBDIR = "crashnotify";
+ /** 缓存崩溃日志文件名 */
+ private static final String CRASH_LOG_CACHE_FILENAME = "crash_log.txt";
+
+ // ====================== 静态成员 ======================
+ private static String sHostPackageName = "";
+ private static String sCrashLogCacheFilePath = "";
+
+ // ====================== 正则表达式定义 ======================
+ private static final String REGEX_EXCEPTION_TYPE = "([\\w.]+Exception|[\\w.]+Error)";
+ private static final String REGEX_EXCEPTION_MESSAGE = "(?<=:\\s)(.+?)(?=\\n|\\r|$)";
+ private static final String REGEX_STACK_TRACE = "\\s+at\\s+([\\w.$]+)\\.([\\w<>]+)\\(([^:]+\\.java):(\\d+)\\)";
+ private static final String REGEX_CAUSE = "(?<=Caused by:\\s)" + REGEX_EXCEPTION_TYPE + "\\s*:";
+
+ // ====================== 对外核心方法 ======================
+ /**
+ * 处理未捕获异常(类库入口核心方法)
+ * @param hostApp 宿主Application实例
+ * @param hostPackageName 宿主应用包名
+ * @param errorLog 崩溃日志内容
+ * @param reportCrashActivity 崩溃详情跳转Activity类
+ */
+ public static void handleUncaughtException(final android.app.Application hostApp,
+ final String hostPackageName,
+ final String errorLog,
+ final Class> reportCrashActivity) {
+ LogUtils.d(TAG, "handleUncaughtException 进入方法");
+ if (hostApp == null || TextUtils.isEmpty(hostPackageName) || TextUtils.isEmpty(errorLog)) {
+ LogUtils.e(TAG, "handleUncaughtException 参数为空校验不通过");
+ return;
+ }
+ sHostPackageName = hostPackageName;
+ final String hostAppName = getHostAppName(hostApp, hostPackageName);
+ final String crashLogFilePath = saveCrashLogToCache(hostApp, errorLog);
+ if (TextUtils.isEmpty(crashLogFilePath)) {
+ LogUtils.e(TAG, "保存崩溃日志到缓存文件失败");
+ return;
+ }
+ sCrashLogCacheFilePath = crashLogFilePath;
+ final Intent shareIntent = new Intent(hostApp, ShareLogActivity.class);
+ shareIntent.putExtra(ShareLogActivity.EXTRA_CRASH_LOG_FILEPATH, crashLogFilePath);
+ shareIntent.putExtra(ShareLogActivity.EXTRA_CRASH_LOG_SUBJECT, "崩溃日志");
+ shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ final PendingIntent sharePendingIntent = createSharePendingIntent(hostApp, shareIntent);
+ sendCrashNotification(hostApp, hostPackageName, hostAppName, errorLog, reportCrashActivity, sharePendingIntent);
+ }
+
+ /**
+ * 重载兼容方法:适配原有CrashHandler调用方式
+ * @param hostApp 宿主Application实例
+ * @param intent 携带崩溃信息Intent
+ * @param reportCrashActivity 崩溃详情Activity
+ */
+ public static void handleUncaughtException(final android.app.Application hostApp,
+ final Intent intent,
+ final Class> reportCrashActivity) {
+ LogUtils.d(TAG, "handleUncaughtException 重载方法进入");
+ String hostPackageName = intent.getStringExtra("EXTRA_HOST_PACKAGE_NAME");
+ if (TextUtils.isEmpty(hostPackageName)) {
+ hostPackageName = hostApp.getPackageName();
+ LogUtils.w(TAG, "未携带宿主包名,默认使用应用自身包名");
+ }
+ final String errorLog = intent.getStringExtra(CrashHandler.EXTRA_CRASH_LOG);
+ handleUncaughtException(hostApp, hostPackageName, errorLog, reportCrashActivity);
+ }
+
+ /**
+ * 资源释放
+ * @param hostContext 宿主上下文
+ */
+ public static void release(final Context hostContext) {
+ LogUtils.d(TAG, "release 执行资源释放");
+ sHostPackageName = "";
+ sCrashLogCacheFilePath = "";
+ }
+
+ // ====================== 内部工具方法 ======================
+ /**
+ * 获取宿主应用名称
+ * @param hostContext 宿主上下文
+ * @param hostPackageName 宿主包名
+ * @return 应用名称,失败返回未知应用
+ */
+ private static String getHostAppName(final Context hostContext, final String hostPackageName) {
+ try {
+ return hostContext.getPackageManager()
+ .getApplicationLabel(hostContext.getPackageManager()
+ .getApplicationInfo(hostPackageName, 0)).toString();
+ } catch (Exception e) {
+ LogUtils.e(TAG, "获取宿主应用名称失败", e);
+ return "未知应用";
+ }
+ }
+
+ /**
+ * 保存崩溃日志到缓存文件
+ * @param hostContext 宿主上下文
+ * @param crashLog 崩溃日志内容
+ * @return 缓存文件路径,失败返回空字符串
+ */
+ private static String saveCrashLogToCache(final Context hostContext, final String crashLog) {
+ if (hostContext == null || TextUtils.isEmpty(crashLog)) {
+ return "";
+ }
+ BufferedReader reader = null;
+ FileOutputStream fos = null;
+ try {
+ final File cacheDir = new File(hostContext.getCacheDir(), CRASH_LOG_CACHE_SUBDIR);
+ if (!cacheDir.exists()) {
+ cacheDir.mkdirs();
+ }
+ final File cacheFile = new File(cacheDir, CRASH_LOG_CACHE_FILENAME);
+ if (cacheFile.exists()) {
+ cacheFile.delete();
+ }
+ cacheFile.createNewFile();
+ fos = new FileOutputStream(cacheFile);
+ fos.write(crashLog.getBytes("UTF-8"));
+ fos.flush();
+ LogUtils.d(TAG, "saveCrashLogToCache 保存崩溃日志到缓存: " + cacheFile.getAbsolutePath());
+ return cacheFile.getAbsolutePath();
+ } catch (Exception e) {
+ LogUtils.e(TAG, "saveCrashLogToCache 异常", e);
+ return "";
+ } finally {
+ if (reader != null) {
+ try { reader.close(); } catch (Exception e) {}
+ }
+ if (fos != null) {
+ try { fos.close(); } catch (Exception e) {}
+ }
+ }
+ }
+
+ /**
+ * 读取缓存的崩溃日志文件内容
+ * @param hostContext 宿主上下文
+ * @return 日志内容,失败返回空字符串
+ */
+ private static String readCachedCrashLog(final Context hostContext) {
+ if (hostContext == null || TextUtils.isEmpty(sCrashLogCacheFilePath)) {
+ return "";
+ }
+ BufferedReader reader = null;
+ try {
+ final File cacheFile = new File(sCrashLogCacheFilePath);
+ if (!cacheFile.exists()) {
+ LogUtils.w(TAG, "readCachedCrashLog 缓存文件不存在");
+ return "";
+ }
+ reader = new BufferedReader(new InputStreamReader(new FileInputStream(cacheFile), "UTF-8"));
+ final StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append("\n");
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ LogUtils.e(TAG, "readCachedCrashLog 异常", e);
+ return "";
+ } finally {
+ if (reader != null) {
+ try { reader.close(); } catch (Exception e) {}
+ }
+ }
+ }
+
+ /**
+ * 发送崩溃系统通知
+ * @param hostContext 宿主上下文
+ * @param hostPackageName 宿主包名
+ * @param hostAppName 宿主应用名
+ * @param errorLog 崩溃日志
+ * @param reportCrashActivity 跳转Activity
+ * @param sharePendingIntent 分享日志PendingIntent
+ */
+ private static void sendCrashNotification(final Context hostContext,
+ final String hostPackageName,
+ final String hostAppName,
+ final String errorLog,
+ final Class> reportCrashActivity,
+ final PendingIntent sharePendingIntent) {
+ final NotificationManager notificationManager =
+ (NotificationManager) hostContext.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (notificationManager == null) {
+ LogUtils.e(TAG, "获取NotificationManager失败");
+ return;
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ createCrashNotifyChannel(hostContext, notificationManager);
+ }
+ final PendingIntent jumpIntent = getGlobalCrashPendingIntent(hostContext,
+ hostPackageName, errorLog, reportCrashActivity);
+ if (jumpIntent == null) {
+ LogUtils.e(TAG, "构建跳转PendingIntent失败");
+ return;
+ }
+ final Notification notification = buildNotification(hostContext, hostPackageName, hostAppName, errorLog, jumpIntent, sharePendingIntent);
+ notificationManager.notify(CRASH_NOTIFY_ID, notification);
+ LogUtils.d(TAG, "崩溃通知发送成功,宿主包名:" + hostPackageName);
+ }
+
+ /**
+ * 创建分享日志PendingIntent
+ * @param hostContext 宿主上下文
+ * @param shareIntent 分享意图
+ * @return PendingIntent实例
+ */
+ private static PendingIntent createSharePendingIntent(final Context hostContext, final Intent shareIntent) {
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= API_LEVEL_ANDROID_12) {
+ flags |= FLAG_IMMUTABLE;
+ }
+ return PendingIntent.getActivity(
+ hostContext,
+ REQUEST_CODE_SHARE_LOG,
+ shareIntent,
+ flags
+ );
+ }
+
+ /**
+ * 创建通知渠道(适配Android O及以上)
+ * @param hostContext 宿主上下文
+ * @param notificationManager 通知管理器
+ */
+ private static void createCrashNotifyChannel(final Context hostContext,
+ final NotificationManager notificationManager) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ android.app.NotificationChannel channel = new android.app.NotificationChannel(
+ CRASH_NOTIFY_CHANNEL_ID,
+ CRASH_NOTIFY_CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_DEFAULT
+ );
+ channel.setDescription("应用崩溃通知(由 WinBoLL Studio 类库提供,点击查看详情)");
+ notificationManager.createNotificationChannel(channel);
+ LogUtils.d(TAG, "通知渠道创建完成");
+ }
+ }
+
+ /**
+ * 构建跳转崩溃详情页PendingIntent
+ * @param hostContext 宿主上下文
+ * @param hostPackageName 宿主包名
+ * @param errorLog 崩溃日志
+ * @param reportCrashActivity 目标Activity
+ * @return PendingIntent实例
+ */
+ private static PendingIntent getGlobalCrashPendingIntent(final Context hostContext,
+ final String hostPackageName,
+ final String errorLog,
+ final Class> reportCrashActivity) {
+ try {
+ final Intent crashIntent = new Intent(hostContext, reportCrashActivity);
+ crashIntent.setPackage(hostPackageName);
+ crashIntent.putExtra(CrashHandler.EXTRA_CRASH_LOG, errorLog);
+ crashIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= API_LEVEL_ANDROID_12) {
+ flags |= FLAG_IMMUTABLE;
+ }
+ return PendingIntent.getActivity(
+ hostContext,
+ CRASH_NOTIFY_ID,
+ crashIntent,
+ flags
+ );
+ } catch (Exception e) {
+ LogUtils.e(TAG, "构建跳转Intent异常", e);
+ return null;
+ }
+ }
+
+ /**
+ * 构建Notification通知实例
+ * @param hostContext 宿主上下文
+ * @param hostPackageName 宿主包名
+ * @param hostAppName 宿主应用名
+ * @param errorLog 崩溃日志
+ * @param jumpIntent 点击跳转意图
+ * @param shareIntent 分享日志意图
+ * @return 构建好的Notification
+ */
+ @SuppressWarnings("deprecation")
+ private static Notification buildNotification(final Context hostContext,
+ final String hostPackageName,
+ final String hostAppName,
+ final String errorLog,
+ final PendingIntent jumpIntent,
+ final PendingIntent shareIntent) {
+ Notification.Builder builder = new Notification.Builder(hostContext);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ builder.setChannelId(CRASH_NOTIFY_CHANNEL_ID);
+ }
+ final String briefInfo = extractBriefInfo(errorLog);
+ final Notification.BigTextStyle bigTextStyle = new Notification.BigTextStyle();
+ bigTextStyle.setBigContentTitle(hostAppName + " 崩溃");
+ bigTextStyle.bigText(briefInfo);
+ bigTextStyle.setSummaryText("点击查看详情");
+ builder.setStyle(bigTextStyle);
+ builder.setSmallIcon(hostContext.getApplicationInfo().icon)
+ .setContentTitle(hostAppName + " 崩溃")
+ .setContentText(briefInfo.split("\n")[0])
+ .setContentIntent(jumpIntent)
+ .setAutoCancel(true)
+ .setWhen(System.currentTimeMillis())
+ .setPriority(Notification.PRIORITY_DEFAULT);
+ if (shareIntent != null) {
+ builder.addAction(android.R.drawable.ic_menu_send, "分享日志", shareIntent);
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ return builder.build();
+ } else {
+ return builder.getNotification();
+ }
+ }
+
+ /**
+ * 截取缩略日志文本
+ * @param content 原始日志
+ * @return 缩略文案
+ */
+ private static String getShortContent(final String errorLog) {
+ if (errorLog == null || errorLog.isEmpty()) {
+ return "无崩溃日志";
+ }
+ final String brief = extractBriefInfo(errorLog);
+ final String firstLine = brief.split("\n")[0];
+ if (firstLine.length() > 80) {
+ return firstLine.substring(0, 80) + "...";
+ }
+ return firstLine;
+ }
+
+ /**
+ * 使用正则表达式从崩溃日志中提取简要信息
+ * @param crashLog 完整崩溃日志
+ * @return 简要崩溃信息
+ */
+ private static String extractBriefInfo(final String crashLog) {
+ if (crashLog == null || crashLog.isEmpty()) {
+ return "无崩溃日志";
+ }
+ final StringBuilder brief = new StringBuilder();
+ try {
+ java.util.regex.Pattern exceptionPattern = java.util.regex.Pattern.compile(REGEX_EXCEPTION_TYPE);
+ java.util.regex.Matcher exceptionMatcher = exceptionPattern.matcher(crashLog);
+ if (exceptionMatcher.find()) {
+ brief.append(exceptionMatcher.group(1));
+ }
+ java.util.regex.Pattern messagePattern = java.util.regex.Pattern.compile(REGEX_EXCEPTION_MESSAGE);
+ java.util.regex.Matcher messageMatcher = messagePattern.matcher(crashLog);
+ if (messageMatcher.find()) {
+ String message = messageMatcher.group(1).trim();
+ if (message.length() > 100) {
+ message = message.substring(0, 100) + "...";
+ }
+ if (brief.length() > 0) {
+ brief.append(" - ");
+ }
+ brief.append(message);
+ }
+ java.util.regex.Pattern causePattern = java.util.regex.Pattern.compile(REGEX_CAUSE);
+ java.util.regex.Matcher causeMatcher = causePattern.matcher(crashLog);
+ if (causeMatcher.find()) {
+ if (brief.length() > 0) {
+ brief.append("\n");
+ }
+ brief.append("原因: ").append(causeMatcher.group(1));
+ }
+ java.util.regex.Pattern stackPattern = java.util.regex.Pattern.compile(REGEX_STACK_TRACE);
+ java.util.regex.Matcher stackMatcher = stackPattern.matcher(crashLog);
+ int lineCount = 0;
+ while (stackMatcher.find() && lineCount < 3) {
+ if (brief.length() > 0) {
+ brief.append("\n");
+ }
+ brief.append(" at ").append(stackMatcher.group(1)).append(".")
+ .append(stackMatcher.group(2)).append("(")
+ .append(stackMatcher.group(3)).append(":")
+ .append(stackMatcher.group(4)).append(")");
+ lineCount++;
+ }
+ if (brief.length() == 0) {
+ brief.append(crashLog.length() > SUMMARY_MAX_LENGTH ? crashLog.substring(0, SUMMARY_MAX_LENGTH) + "..." : crashLog);
+ }
+ } catch (Exception e) {
+ LogUtils.e(TAG, "提取崩溃简要信息失败", e);
+ brief.setLength(0);
+ brief.append(crashLog.length() > SUMMARY_MAX_LENGTH ? crashLog.substring(0, SUMMARY_MAX_LENGTH) + "..." : crashLog);
+ }
+ return brief.toString();
+ }
+}
\ No newline at end of file
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/FTPUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/FTPUtils.java
new file mode 100644
index 0000000..7e1ea28
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/FTPUtils.java
@@ -0,0 +1,487 @@
+package cc.winboll.studio.libappbase.utils;
+
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.models.SFTPAuthModel;
+import com.jcraft.jsch.ChannelSftp;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import com.jcraft.jsch.SftpATTRS;
+import com.jcraft.jsch.SftpException;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Properties;
+import java.util.Vector;
+
+/**
+ * SFTP/FTP工具类(单例模式)- Java7兼容 · 适配FTPAuthModel实体类
+ * 底层严格基于JSch 0.1.54原生ChannelSftp+SftpException接口实现,替换原commons-net FTP
+ * 核心功能:登录/登出、文件上传/下载、文件夹列举、文件/文件夹存在性判断
+ * 依赖:com.jcraft:jsch:0.1.54
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/30 19:04
+ */
+public class FTPUtils {
+ // 单例实例(双重校验锁 volatile 保证可见性,Java7兼容)
+ private static volatile FTPUtils sInstance;
+ // JSch核心对象:Session(连接会话)、ChannelSftp(SFTP通道)
+ private JSch mJSch;
+ private Session mSession;
+ private ChannelSftp mSftpChannel;
+ // 日志TAG
+ public static final String TAG = "FTPUtils";
+ // SFTP默认端口(FTPAuthModel未设置时使用)
+ private static final int DEFAULT_SFTP_PORT = 22;
+ // 连接超时时间 5s(Java7原生Socket超时)
+ private static final int CONNECT_TIMEOUT = 5000;
+
+ // 私有构造器:禁止外部实例化
+ private FTPUtils() {
+ initSftpClient();
+ }
+
+ /**
+ * 获取单例实例(双重校验锁,线程安全,Java7兼容)
+ * @return FTPUtils 单例
+ */
+ public static FTPUtils getInstance() {
+ if (sInstance == null) {
+ synchronized (FTPUtils.class) {
+ if (sInstance == null) {
+ sInstance = new FTPUtils();
+ }
+ }
+ }
+ return sInstance;
+ }
+
+ /**
+ * 初始化SFTP客户端(JSch),创建核心原生对象
+ */
+ private void initSftpClient() {
+ if (mJSch == null) {
+ mJSch = new JSch();
+ LogUtils.d(TAG, "SFTP客户端(JSch)初始化完成");
+ }
+ // 重置会话和通道,避免连接残留
+ mSession = null;
+ mSftpChannel = null;
+ }
+
+ /**
+ * 【推荐】SFTP登录(基于FTPAuthModel实体类,完全兼容原有参数)
+ * @param ftpAuthModel 登录配置实体类(不能为空,端口默认22,编码默认UTF-8)
+ * @return 登录成功返回true,失败false
+ */
+ public boolean login(SFTPAuthModel ftpAuthModel) {
+ // 1. 实体类非空校验
+ if (ftpAuthModel == null) {
+ LogUtils.e(TAG, "SFTP登录失败:FTPAuthModel实体类为null");
+ return false;
+ }
+ // 2. 核心参数校验(服务器地址不能为空)
+ if (isParamEmpty(ftpAuthModel.getFtpServer())) {
+ LogUtils.e(TAG, "SFTP登录失败:服务器地址(ftpServer)不能为空");
+ return false;
+ }
+ // 3. 若已连接,先断开
+ if (isConnected()) {
+ logout();
+ }
+ // 4. 重新初始化客户端
+ initSftpClient();
+
+ try {
+ // 获取服务器地址、端口(默认22)、账号、密码
+ String host = ftpAuthModel.getFtpServer();
+ int port = ftpAuthModel.getFtpPort() <= 0 ? DEFAULT_SFTP_PORT : ftpAuthModel.getFtpPort();
+ String username = ftpAuthModel.getFtpUsername();
+ String password = ftpAuthModel.getFtpPassword();
+
+ // SFTP不支持匿名登录,账号密码不能为空(原生接口无匿名登录能力)
+ if (isParamEmpty(username) || isParamEmpty(password)) {
+ LogUtils.e(TAG, "SFTP登录失败:SFTP不支持匿名登录,请配置有效账号密码");
+ return false;
+ }
+
+ // 1. 创建JSch会话(原生接口)
+ mSession = mJSch.getSession(username, host, port);
+ mSession.setPassword(password);
+
+ // 2. 设置会话属性(跳过SSH密钥校验,适配大部分服务器)
+ Properties sessionProps = new Properties();
+ sessionProps.put("StrictHostKeyChecking", "no");
+ sessionProps.put("PreferredAuthentications", "password");
+ mSession.setConfig(sessionProps);
+
+ // 3. 设置会话连接超时(原生接口,底层Socket超时)
+ mSession.setTimeout(CONNECT_TIMEOUT);
+
+ // 4. 建立会话连接(原生接口)
+ mSession.connect();
+ LogUtils.d(TAG, "SFTP会话连接成功:" + host + ":" + port);
+
+ // 5. 打开SFTP通道(类型:sftp,原生接口强转)
+ mSftpChannel = (ChannelSftp) mSession.openChannel("sftp");
+ mSftpChannel.connect();
+
+ // 6. 设置文件名编码(解决中文乱码,ChannelSftp原生接口)
+ String charset = isParamEmpty(ftpAuthModel.getFtpCharset()) ? "UTF-8" : ftpAuthModel.getFtpCharset();
+ mSftpChannel.setFilenameEncoding(charset);
+ LogUtils.d(TAG, "SFTP文件名编码设置成功:" + charset);
+
+ LogUtils.i(TAG, "SFTP登录成功,服务器:" + host + ":" + port + ",用户名:" + username);
+ return true;
+
+ } catch (JSchException e) {
+ LogUtils.e(TAG, "SFTP登录JSch异常:" + e.getMessage(), e);
+ logout();
+ return false;
+ } catch (SftpException e) {
+ // 匹配SftpException原生属性和方法
+ LogUtils.e(TAG, "SFTP通道初始化异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
+ logout();
+ return false;
+ }
+ }
+
+ /**
+ * 【已废弃】原FTP多参数登录方法,适配JSch后保留,推荐使用login(FTPAuthModel)
+ * @deprecated 请使用基于FTPAuthModel的登录方法
+ */
+ @Deprecated
+ public boolean login(String host, int port, String username, String password) {
+ SFTPAuthModel ftpAuthModel = new SFTPAuthModel();
+ ftpAuthModel.setFtpServer(host);
+ ftpAuthModel.setFtpPort(port <= 0 ? DEFAULT_SFTP_PORT : port);
+ ftpAuthModel.setFtpUsername(username);
+ ftpAuthModel.setFtpPassword(password);
+ return login(ftpAuthModel);
+ }
+
+ /**
+ * SFTP登出并断开连接,释放所有资源(严格调用原生disconnect接口)
+ * @return 登出成功返回true,失败false
+ */
+ public boolean logout() {
+ boolean isSuccess = true;
+ // 关闭SFTP通道(原生接口disconnect,非空判断即可)
+ if (mSftpChannel != null) {
+ try {
+ mSftpChannel.disconnect();
+ LogUtils.d(TAG, "SFTP通道已断开");
+ } catch (Exception e) {
+ LogUtils.e(TAG, "关闭SFTP通道异常:" + e.getMessage(), e);
+ isSuccess = false;
+ }
+ }
+ // 关闭JSch会话(原生接口disconnect,非空判断即可)
+ if (mSession != null) {
+ try {
+ mSession.disconnect();
+ LogUtils.d(TAG, "SFTP会话已断开");
+ } catch (Exception e) {
+ LogUtils.e(TAG, "关闭SFTP会话异常:" + e.getMessage(), e);
+ isSuccess = false;
+ }
+ }
+ // 重置客户端,避免资源残留
+ initSftpClient();
+ if (isSuccess) {
+ LogUtils.i(TAG, "SFTP登出成功");
+ } else {
+ LogUtils.w(TAG, "SFTP登出失败:部分资源未正常释放");
+ }
+ return isSuccess;
+ }
+
+ /**
+ * 强制断开连接(兜底资源释放),同logout方法
+ */
+ public void disconnect() {
+ logout();
+ }
+
+ /**
+ * 判断SFTP是否已连接(会话+通道均调用原生isConnected接口)
+ * @return 已连接返回true,否则false
+ */
+ public boolean isConnected() {
+ return mSession != null && mSession.isConnected()
+ && mSftpChannel != null && mSftpChannel.isConnected();
+ }
+
+ /**
+ * 上传文件到SFTP指定路径(覆盖式上传,调用ChannelSftp原生put接口,OVERWRITE模式)
+ * @param localFilePath 本地文件绝对路径(如/sdcard/test.apk)
+ * @param remoteFilePath SFTP服务器目标路径(如/ftp/apk/test.apk,需包含文件名)
+ * @return 上传成功返回true,失败false
+ */
+ public boolean uploadFile(String localFilePath, String remoteFilePath) {
+ // 前置校验
+ if (!isConnected()) {
+ LogUtils.e(TAG, "文件上传失败:SFTP未连接服务器");
+ return false;
+ }
+ if (isParamEmpty(localFilePath) || isParamEmpty(remoteFilePath)) {
+ LogUtils.e(TAG, "文件上传失败:本地/远程路径不能为空");
+ return false;
+ }
+ File localFile = new File(localFilePath);
+ if (!localFile.exists() || !localFile.isFile()) {
+ LogUtils.e(TAG, "文件上传失败:本地文件不存在/非文件,路径:" + localFilePath);
+ return false;
+ }
+
+ InputStream fis = null;
+ try {
+ // 自动创建远程多级目录(基于原生mkdir/stat接口)
+ createRemoteDir(remoteFilePath);
+ // 读取本地文件,上传到SFTP(原生put接口,OVERWRITE覆盖模式)
+ fis = new FileInputStream(localFile);
+ mSftpChannel.put(fis, remoteFilePath, ChannelSftp.OVERWRITE);
+ LogUtils.i(TAG, "文件上传成功:本地" + localFilePath + " → 远程" + remoteFilePath);
+ return true;
+ } catch (IOException e) {
+ LogUtils.e(TAG, "文件上传IO异常:" + e.getMessage(), e);
+ return false;
+ } catch (SftpException e) {
+ // 严格匹配SftpException原生属性:id、getMessage()、toString()
+ LogUtils.e(TAG, "文件上传SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
+ return false;
+ } finally {
+ // 关闭流资源,避免内存泄漏
+ closeStream(fis, null);
+ }
+ }
+
+ /**
+ * 从SFTP下载文件到本地指定路径(覆盖式下载,调用ChannelSftp原生get接口)
+ * @param remoteFilePath SFTP服务器文件路径(如/ftp/apk/test.apk)
+ * @param localFilePath 本地目标路径(如/sdcard/test.apk,需包含文件名)
+ * @return 下载成功返回true,失败false
+ */
+ public boolean downloadFile(String remoteFilePath, String localFilePath) {
+ // 前置校验
+ if (!isConnected()) {
+ LogUtils.e(TAG, "文件下载失败:SFTP未连接服务器");
+ return false;
+ }
+ if (isParamEmpty(remoteFilePath) || isParamEmpty(localFilePath)) {
+ LogUtils.e(TAG, "文件下载失败:远程/本地路径不能为空");
+ return false;
+ }
+ // 校验远程文件是否存在(基于ChannelSftp原生stat接口)
+ if (!isFileExists(remoteFilePath)) {
+ LogUtils.e(TAG, "文件下载失败:远程文件不存在,路径:" + remoteFilePath);
+ return false;
+ }
+
+ OutputStream fos = null;
+ try {
+ // 创建本地多级目录
+ File localFile = new File(localFilePath);
+ File parentDir = localFile.getParentFile();
+ if (!parentDir.exists() && !parentDir.mkdirs()) {
+ LogUtils.e(TAG, "文件下载失败:创建本地目录失败,路径:" + parentDir.getAbsolutePath());
+ return false;
+ }
+ // 从SFTP读取文件,写入本地(原生get接口)
+ fos = new FileOutputStream(localFile);
+ mSftpChannel.get(remoteFilePath, fos);
+ LogUtils.i(TAG, "文件下载成功:远程" + remoteFilePath + " → 本地" + localFilePath);
+ return true;
+ } catch (IOException e) {
+ LogUtils.e(TAG, "文件下载IO异常:" + e.getMessage(), e);
+ // 删除未下载完成的本地文件
+ new File(localFilePath).delete();
+ return false;
+ } catch (SftpException e) {
+ // 严格匹配SftpException原生属性:id、getMessage()、toString()
+ LogUtils.e(TAG, "文件下载SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
+ // 删除未下载完成的本地文件
+ new File(localFilePath).delete();
+ return false;
+ } finally {
+ // 关闭流资源,避免内存泄漏
+ closeStream(null, fos);
+ }
+ }
+
+ /**
+ * 列举SFTP指定文件夹下的所有文件/文件夹(返回ChannelSftp原生Vector,过滤.和..)
+ * @param remoteDir SFTP服务器目录路径(如/ftp/apk/,结尾带/或不带均可)
+ * @return 成功返回原生Vector,失败返回空Vector
+ */
+ @SuppressWarnings("rawtypes")
+ public Vector listDir(String remoteDir) {
+ Vector fileList = new Vector();
+ // 前置校验
+ if (!isConnected()) {
+ LogUtils.e(TAG, "列举目录失败:SFTP未连接服务器");
+ return fileList;
+ }
+ if (isParamEmpty(remoteDir)) {
+ LogUtils.e(TAG, "列举目录失败:远程目录路径不能为空");
+ return fileList;
+ }
+ // 校验目录是否存在(基于ChannelSftp原生stat接口)
+ if (!isDirExists(remoteDir)) {
+ LogUtils.e(TAG, "列举目录失败:远程目录不存在,路径:" + remoteDir);
+ return fileList;
+ }
+
+ try {
+ // 列举目录下所有文件/文件夹(调用ChannelSftp原生ls接口,返回原生Vector)
+ Vector vector = mSftpChannel.ls(remoteDir);
+ if (vector != null && vector.size() > 0) {
+ for (Object obj : vector) {
+ // 过滤.和..上级目录,仅保留有效文件/目录
+ ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
+ String fileName = entry.getFilename();
+ if (!".".equals(fileName) && !"..".equals(fileName)) {
+ fileList.add(obj);
+ }
+ }
+ }
+ LogUtils.i(TAG, "列举目录成功:" + remoteDir + ",共" + fileList.size() + "个文件/文件夹");
+ } catch (SftpException e) {
+ // 严格匹配SftpException原生属性:id、getMessage()、toString()
+ LogUtils.e(TAG, "列举目录SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
+ }
+ return fileList;
+ }
+
+ /**
+ * 判断SFTP服务器上**文件**是否存在(基于ChannelSftp原生stat接口,匹配SftpException原生异常)
+ * @param remoteFilePath SFTP服务器文件路径(如/ftp/apk/test.apk)
+ * @return 存在且为文件返回true,否则false
+ */
+ public boolean isFileExists(String remoteFilePath) {
+ // 前置校验
+ if (!isConnected()) {
+ LogUtils.e(TAG, "判断文件存在性失败:SFTP未连接服务器");
+ return false;
+ }
+ if (isParamEmpty(remoteFilePath)) {
+ LogUtils.e(TAG, "判断文件存在性失败:远程文件路径不能为空");
+ return false;
+ }
+
+ try {
+ // 调用ChannelSftp原生stat接口获取属性,不存在会抛出SSH_FX_NO_SUCH_FILE异常
+ SftpATTRS attrs = mSftpChannel.stat(remoteFilePath);
+ // 原生isReg()判断是否为文件
+ return attrs.isReg();
+ } catch (SftpException e) {
+ // 仅匹配原生异常码SSH_FX_NO_SUCH_FILE(2):文件/目录不存在,不记错误日志
+ if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
+ LogUtils.e(TAG, "判断文件存在性SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
+ }
+ return false;
+ }
+ }
+
+ /**
+ * 判断SFTP服务器上**文件夹**是否存在(基于ChannelSftp原生stat接口,匹配SftpException原生异常)
+ * @param remoteDir SFTP服务器目录路径(如/ftp/apk/,结尾带/或不带均可)
+ * @return 存在且为目录返回true,否则false
+ */
+ public boolean isDirExists(String remoteDir) {
+ // 前置校验
+ if (!isConnected()) {
+ LogUtils.e(TAG, "判断目录存在性失败:SFTP未连接服务器");
+ return false;
+ }
+ if (isParamEmpty(remoteDir)) {
+ LogUtils.e(TAG, "判断目录存在性失败:远程目录路径不能为空");
+ return false;
+ }
+
+ try {
+ // 调用ChannelSftp原生stat接口获取属性,不存在会抛出SSH_FX_NO_SUCH_FILE异常
+ SftpATTRS attrs = mSftpChannel.stat(remoteDir);
+ // 原生isDir()判断是否为目录
+ return attrs.isDir();
+ } catch (SftpException e) {
+ // 仅匹配原生异常码SSH_FX_NO_SUCH_FILE(2):文件/目录不存在,不记错误日志
+ if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
+ LogUtils.e(TAG, "判断目录存在性SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
+ }
+ return false;
+ }
+ }
+
+ // ===================================== 内部工具方法(仅调用原生接口) =====================================
+ /**
+ * 递归创建SFTP远程多级目录(基于ChannelSftp原生mkdir/stat接口,不存在则创建)
+ * @param remoteFilePath SFTP远程文件路径/目录路径
+ */
+ private void createRemoteDir(String remoteFilePath) {
+ if (!isConnected()) {
+ LogUtils.e(TAG, "创建远程目录失败:SFTP未连接服务器");
+ return;
+ }
+ try {
+ // 提取目录路径(文件路径→目录路径,目录路径直接使用)
+ String remoteDir = remoteFilePath.lastIndexOf("/") > 0
+ ? remoteFilePath.substring(0, remoteFilePath.lastIndexOf("/"))
+ : remoteFilePath;
+ // 按/分割多级目录,递归创建(避免多级目录不存在)
+ String[] dirs = remoteDir.split("/");
+ StringBuilder currentDir = new StringBuilder();
+ for (String dir : dirs) {
+ if (isParamEmpty(dir)) {
+ continue;
+ }
+ currentDir.append("/").append(dir);
+ String dirPath = currentDir.toString();
+ // 目录不存在则调用ChannelSftp原生mkdir创建
+ if (!isDirExists(dirPath)) {
+ mSftpChannel.mkdir(dirPath);
+ LogUtils.d(TAG, "创建SFTP远程目录成功:" + dirPath);
+ }
+ }
+ } catch (SftpException e) {
+ // 严格匹配SftpException原生属性:id、getMessage()、toString()
+ LogUtils.e(TAG, "创建远程目录SFTP异常:id=" + e.id + ",msg=" + e.getMessage() + ",detail=" + e.toString());
+ }
+ }
+
+ /**
+ * 关闭流资源(通用工具方法,Java7原生IO,避免内存泄漏)
+ * @param is 输入流(可为null)
+ * @param os 输出流(可为null)
+ */
+ private void closeStream(InputStream is, OutputStream os) {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ LogUtils.e(TAG, "关闭输入流异常:" + e.getMessage(), e);
+ }
+ }
+ if (os != null) {
+ try {
+ os.close();
+ } catch (IOException e) {
+ LogUtils.e(TAG, "关闭输出流异常:" + e.getMessage(), e);
+ }
+ }
+ }
+
+ /**
+ * 判断参数是否为空(null/空字符串/全空格,Java7原生字符串操作)
+ * @param param 待判断参数
+ * @return 为空返回true,否则false
+ */
+ private boolean isParamEmpty(String param) {
+ return param == null || param.trim().isEmpty();
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/NfcRsaAuthTool.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/NfcRsaAuthTool.java
new file mode 100644
index 0000000..655b73f
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/NfcRsaAuthTool.java
@@ -0,0 +1,480 @@
+package cc.winboll.studio.libappbase.utils;
+
+import android.content.Context;
+import android.nfc.NfcAdapter;
+import android.nfc.Tag;
+import android.nfc.tech.Ndef;
+import android.nfc.tech.NdefFormatable;
+import android.util.Base64;
+import cc.winboll.studio.libappbase.LogUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.RSAPrivateCrtKeySpec;
+import java.security.spec.RSAPublicKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import javax.crypto.Cipher;
+
+/**
+ * @Describe NFC RSA认证工具类,单例模式
+ * 核心功能:RSA密钥生成、NFC密钥读写、本地data区密钥存储、密钥有效性校验、内存密钥缓存
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/11 21:00:00
+ * @LastEditTime 2026/01/12 17:46:00
+ */
+public class NfcRsaAuthTool {
+ // 常量配置(集中管理,简洁无冗余)
+ private static final String TAG = "NfcRsaAuthTool";
+ private static final String RSA_ALGORITHM = "RSA";
+ private static final int RSA_KEY_SIZE = 2048;
+ private static final String NFC_KEY_TAG = "RSA_AUTH_PRIV_";
+ private static final String CHARSET = "UTF-8";
+ private static final String LOCAL_KEY_FILE_NAME = "rsa_auth_private.key";
+ private static final String RSA_TEST_DATA = "NFC_RSA_AUTH_VALID";
+
+ // 单例实例(线程安全双重校验锁核心)
+ private static volatile NfcRsaAuthTool sInstance;
+
+ // 核心属性(按用途排序,注释清晰)
+ private Context mContext;
+ private NfcAdapter mNfcAdapter;
+ private String mCachePrivateKeyStr; // 内存缓存Base64私钥字符串
+ private String mCachePublicKeyStr; // 内存缓存Base64公钥字符串
+
+ // 私有构造器(禁止外部实例化,绑定全局上下文)
+ private NfcRsaAuthTool(Context context) {
+ this.mContext = context.getApplicationContext();
+ this.mNfcAdapter = NfcAdapter.getDefaultAdapter(mContext);
+ LogUtils.d(TAG, "构造初始化完成,已绑定全局上下文");
+ }
+
+ /**
+ * 获取单例实例(线程安全,双重校验锁,适配多线程场景)
+ * @param context 上下文对象
+ * @return 单例工具类实例
+ */
+ public static NfcRsaAuthTool getInstance(Context context) {
+ if (sInstance == null) {
+ synchronized (NfcRsaAuthTool.class) {
+ if (sInstance == null) {
+ sInstance = new NfcRsaAuthTool(context);
+ LogUtils.d(TAG, "首次创建单例实例成功");
+ }
+ }
+ }
+ LogUtils.d(TAG, "获取单例实例成功");
+ return sInstance;
+ }
+
+ // ==================== 核心功能1:生成RSA私钥(返回Base64编码字符串,便于存储) ====================
+ public String generateRsaPrivateKey() {
+ LogUtils.d(TAG, "开始生成RSA私钥,密钥长度:" + RSA_KEY_SIZE);
+ try {
+ KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(RSA_ALGORITHM);
+ keyPairGen.initialize(RSA_KEY_SIZE);
+ KeyPair keyPair = keyPairGen.generateKeyPair();
+ PrivateKey privateKey = keyPair.getPrivate();
+ String privateKeyStr = Base64.encodeToString(privateKey.getEncoded(), Base64.NO_WRAP);
+ LogUtils.d(TAG, "RSA私钥生成成功,已完成Base64编码");
+ return privateKeyStr;
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e(TAG, "RSA私钥生成失败,无对应算法支持", e);
+ return null;
+ }
+ }
+
+ // ==================== 核心功能2:NFC密钥读写(适配NDEF标签,兼容已格式化/未格式化场景) ====================
+ /**
+ * 写入Base64私钥到NFC标签,带专属标识防数据混淆
+ * @param tag NFC标签对象
+ * @param privateKeyStr Base64编码私钥字符串
+ * @return 写入成功返回true,失败返回false
+ */
+ public boolean writePrivateKeyToNfc(Tag tag, String privateKeyStr) {
+ LogUtils.d(TAG, "写入NFC私钥,入参校验:Tag=" + tag + ",私钥非空=" + (privateKeyStr != null && !privateKeyStr.isEmpty()));
+ if (tag == null || privateKeyStr == null || privateKeyStr.isEmpty() || mNfcAdapter == null) {
+ LogUtils.w(TAG, "入参无效,写入失败(Tag/NFC适配器为空或私钥为空)");
+ return false;
+ }
+ try {
+ byte[] writeData = (NFC_KEY_TAG + privateKeyStr).getBytes(CHARSET);
+ boolean result = writeNfcData(tag, writeData);
+ LogUtils.d(TAG, "NFC私钥写入结果:" + result);
+ return result;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "NFC私钥写入异常", e);
+ return false;
+ }
+ }
+
+ /**
+ * 从NFC标签读取私钥,自动校验标识并缓存到内存
+ * @param tag NFC标签对象
+ * @return 有效私钥返回Base64字符串,无效返回null
+ */
+ public String readPrivateKeyFromNfc(Tag tag) {
+ LogUtils.d(TAG, "读取NFC私钥,Tag对象:" + tag);
+ if (tag == null || mNfcAdapter == null) {
+ LogUtils.w(TAG, "Tag或NFC适配器为空,读取失败");
+ return null;
+ }
+ try {
+ byte[] nfcData = readNfcData(tag);
+ if (nfcData == null || nfcData.length == 0) {
+ LogUtils.w(TAG, "NFC标签无有效存储数据");
+ return null;
+ }
+ String allDataStr = new String(nfcData, CHARSET);
+ if (!allDataStr.startsWith(NFC_KEY_TAG)) {
+ LogUtils.w(TAG, "NFC数据无专属标识,判定为无效私钥数据");
+ return null;
+ }
+ String privateKeyStr = allDataStr.substring(NFC_KEY_TAG.length());
+ if (!privateKeyStr.isEmpty()) {
+ mCachePrivateKeyStr = privateKeyStr;
+ extractPublicKeyFromPrivateKeyStr(privateKeyStr);
+ LogUtils.d(TAG, "NFC私钥读取成功,已缓存私钥并提取公钥");
+ }
+ return privateKeyStr;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "NFC私钥读取异常", e);
+ return null;
+ }
+ }
+
+ // ==================== 核心功能3:本地data区密钥存储(仅应用可访问,安全存储) ====================
+ /**
+ * 私钥存储到应用内部data区,同步缓存到内存
+ * @param privateKeyStr Base64编码私钥字符串
+ * @return 存储成功返回true,失败返回false
+ */
+ public boolean savePrivateKeyToLocal(String privateKeyStr) {
+ LogUtils.d(TAG, "本地存储私钥,私钥非空校验:" + (privateKeyStr != null && !privateKeyStr.isEmpty()));
+ if (privateKeyStr == null || privateKeyStr.isEmpty()) {
+ LogUtils.w(TAG, "待存储私钥为空,存储失败");
+ return false;
+ }
+ File keyFile = new File(mContext.getFilesDir(), LOCAL_KEY_FILE_NAME);
+ FileOutputStream fos = null;
+ try {
+ fos = new FileOutputStream(keyFile);
+ fos.write(privateKeyStr.getBytes(CHARSET));
+ fos.flush();
+ mCachePrivateKeyStr = privateKeyStr;
+ extractPublicKeyFromPrivateKeyStr(privateKeyStr);
+ LogUtils.d(TAG, "私钥本地存储成功,存储路径:" + keyFile.getAbsolutePath());
+ return true;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "私钥本地存储失败", e);
+ return false;
+ } finally {
+ if (fos != null) {
+ try {
+ fos.close();
+ } catch (IOException e) {
+ LogUtils.w(TAG, "关闭存储输出流异常", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * 从本地data区读取私钥,同步缓存到内存
+ * @return 本地私钥返回Base64字符串,无文件返回null
+ */
+ public String getLocalPrivateKey() {
+ LogUtils.d(TAG, "开始读取本地存储私钥");
+ File keyFile = new File(mContext.getFilesDir(), LOCAL_KEY_FILE_NAME);
+ if (!keyFile.exists()) {
+ LogUtils.w(TAG, "本地私钥文件不存在");
+ return null;
+ }
+ FileInputStream fis = null;
+ ByteArrayOutputStream bos = null;
+ try {
+ fis = new FileInputStream(keyFile);
+ bos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int len;
+ while ((len = fis.read(buffer)) != -1) {
+ bos.write(buffer, 0, len);
+ }
+ String privateKeyStr = new String(bos.toByteArray(), CHARSET);
+ mCachePrivateKeyStr = privateKeyStr;
+ extractPublicKeyFromPrivateKeyStr(privateKeyStr);
+ LogUtils.d(TAG, "本地私钥读取成功,已同步缓存");
+ return privateKeyStr;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "本地私钥读取失败", e);
+ return null;
+ } finally {
+ try {
+ if (fis != null) fis.close();
+ if (bos != null) bos.close();
+ } catch (IOException e) {
+ LogUtils.w(TAG, "关闭读取流异常", e);
+ }
+ }
+ }
+
+ // ==================== 核心功能4:私钥提取公钥(自动缓存,全局可用) ====================
+ public String extractPublicKeyFromPrivateKeyStr(String privateKeyStr) {
+ LogUtils.d(TAG, "从私钥提取公钥,私钥非空校验:" + (privateKeyStr != null && !privateKeyStr.isEmpty()));
+ if (privateKeyStr == null || privateKeyStr.isEmpty()) {
+ LogUtils.w(TAG, "待提取私钥为空,提取失败");
+ return null;
+ }
+ try {
+ byte[] priKeyBytes = Base64.decode(privateKeyStr, Base64.NO_WRAP);
+ PKCS8EncodedKeySpec priSpec = new PKCS8EncodedKeySpec(priKeyBytes);
+ KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
+ PrivateKey privateKey = keyFactory.generatePrivate(priSpec);
+
+ RSAPrivateCrtKeySpec privateCrtSpec = (RSAPrivateCrtKeySpec) keyFactory.getKeySpec(privateKey, RSAPrivateCrtKeySpec.class);
+ RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(privateCrtSpec.getModulus(), privateCrtSpec.getPublicExponent());
+ PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
+
+ String publicKeyStr = Base64.encodeToString(publicKey.getEncoded(), Base64.NO_WRAP);
+ mCachePublicKeyStr = publicKeyStr;
+ LogUtils.d(TAG, "公钥提取成功,已缓存");
+ return publicKeyStr;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "公钥提取失败", e);
+ return null;
+ }
+ }
+
+ // ==================== 核心功能5:密钥有效性校验(私钥自校验,公钥交叉校验) ====================
+ /**
+ * 私钥有效性校验:自加密自解密测试明文
+ * @param privateKeyStr Base64编码私钥字符串
+ * @return 有效返回true,无效返回false
+ */
+ public boolean validatePrivateKey(String privateKeyStr) {
+ LogUtils.d(TAG, "开始校验私钥有效性");
+ if (privateKeyStr == null || privateKeyStr.isEmpty()) {
+ LogUtils.w(TAG, "待校验私钥为空,直接判定无效");
+ return false;
+ }
+ try {
+ byte[] priBytes = Base64.decode(privateKeyStr, Base64.NO_WRAP);
+ PKCS8EncodedKeySpec priSpec = new PKCS8EncodedKeySpec(priBytes);
+ PrivateKey privateKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePrivate(priSpec);
+
+ Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
+ cipher.init(Cipher.ENCRYPT_MODE, privateKey);
+ byte[] encryptData = cipher.doFinal(RSA_TEST_DATA.getBytes(CHARSET));
+ cipher.init(Cipher.DECRYPT_MODE, privateKey);
+ byte[] decryptData = cipher.doFinal(encryptData);
+
+ boolean valid = RSA_TEST_DATA.equals(new String(decryptData, CHARSET));
+ LogUtils.d(TAG, "私钥有效性校验结果:" + valid);
+ return valid;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "私钥校验异常,判定无效", e);
+ return false;
+ }
+ }
+
+ /**
+ * 公钥有效性校验:私钥加密+公钥解密测试明文
+ * @param privateKeyStr 基准Base64私钥字符串
+ * @param publicKeyStr 待校验Base64公钥字符串
+ * @return 有效返回true,无效返回false
+ */
+ public boolean validatePublicKey(String privateKeyStr, String publicKeyStr) {
+ LogUtils.d(TAG, "开始校验公钥有效性,私钥非空=" + (privateKeyStr != null && !privateKeyStr.isEmpty()) + ",公钥非空=" + (publicKeyStr != null && !publicKeyStr.isEmpty()));
+ if (privateKeyStr == null || publicKeyStr == null || privateKeyStr.isEmpty() || publicKeyStr.isEmpty()) {
+ LogUtils.w(TAG, "私钥或公钥为空,直接判定无效");
+ return false;
+ }
+ try {
+ PrivateKey privateKey = getPrivateKeyFromStr(privateKeyStr);
+ PublicKey publicKey = getPublicKeyFromStr(publicKeyStr);
+ if (privateKey == null || publicKey == null) {
+ LogUtils.w(TAG, "私钥或公钥转对象失败,判定无效");
+ return false;
+ }
+
+ Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
+ cipher.init(Cipher.ENCRYPT_MODE, privateKey);
+ byte[] encryptData = cipher.doFinal(RSA_TEST_DATA.getBytes(CHARSET));
+
+ cipher.init(Cipher.DECRYPT_MODE, publicKey);
+ byte[] decryptData = cipher.doFinal(encryptData);
+
+ boolean valid = RSA_TEST_DATA.equals(new String(decryptData, CHARSET));
+ LogUtils.d(TAG, "公钥有效性校验结果:" + valid);
+ return valid;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "公钥校验异常,判定无效", e);
+ return false;
+ }
+ }
+
+ // ==================== 内部辅助方法(密钥字符串转对象,仅工具类内部调用) ====================
+ /**
+ * 内部辅助:Base64私钥字符串转PrivateKey对象
+ * @param privateKeyStr Base64编码私钥字符串
+ * @return 转换成功返回对象,失败返回null
+ */
+ private PrivateKey getPrivateKeyFromStr(String privateKeyStr) {
+ LogUtils.d(TAG, "私钥字符串转PrivateKey对象");
+ if (privateKeyStr == null || privateKeyStr.isEmpty()) {
+ LogUtils.w(TAG, "私钥字符串为空,转换失败");
+ return null;
+ }
+ try {
+ byte[] priBytes = Base64.decode(privateKeyStr, Base64.NO_WRAP);
+ PKCS8EncodedKeySpec priSpec = new PKCS8EncodedKeySpec(priBytes);
+ return KeyFactory.getInstance(RSA_ALGORITHM).generatePrivate(priSpec);
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e(TAG, "设备不支持RSA算法,私钥转换失败", e);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "私钥格式无效,转换失败", e);
+ }
+ return null;
+ }
+
+ /**
+ * 内部辅助:Base64公钥字符串转PublicKey对象
+ * @param publicKeyStr Base64编码公钥字符串
+ * @return 转换成功返回对象,失败返回null
+ */
+ private PublicKey getPublicKeyFromStr(String publicKeyStr) {
+ LogUtils.d(TAG, "公钥字符串转PublicKey对象");
+ if (publicKeyStr == null || publicKeyStr.isEmpty()) {
+ LogUtils.w(TAG, "公钥字符串为空,转换失败");
+ return null;
+ }
+ try {
+ byte[] pubBytes = Base64.decode(publicKeyStr, Base64.NO_WRAP);
+ X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(pubBytes);
+ return KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(pubSpec);
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e(TAG, "设备不支持RSA算法,公钥转换失败", e);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "公钥格式无效,转换失败", e);
+ }
+ return null;
+ }
+
+ // ==================== 内部NFC读写辅助方法(底层交互,对外隐藏) ====================
+ /**
+ * 内部辅助:读取NFC标签原始字节数据
+ */
+ private byte[] readNfcData(Tag tag) {
+ Ndef ndef = Ndef.get(tag);
+ if (ndef != null) {
+ try {
+ ndef.connect();
+ byte[] data = ndef.getNdefMessage().getRecords()[0].getPayload();
+ ndef.close();
+ return data;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "NDEF格式NFC读取异常", e);
+ try { ndef.close(); } catch (IOException ex) { LogUtils.w(TAG, "关闭Ndef连接异常", ex); }
+ }
+ }
+ LogUtils.w(TAG, "NFC标签非NDEF格式,无有效数据");
+ return null;
+ }
+
+ /**
+ * 内部辅助:写入字节数据到NFC标签,兼容两种标签状态
+ */
+ private boolean writeNfcData(Tag tag, byte[] data) {
+ Ndef ndef = Ndef.get(tag);
+ if (ndef != null) return writeToNdef(ndef, data);
+
+ NdefFormatable formatable = NdefFormatable.get(tag);
+ if (formatable != null) return writeToFormatable(formatable, data);
+
+ LogUtils.w(TAG, "NFC标签不支持NDEF格式,写入失败");
+ return false;
+ }
+
+ /**
+ * 内部辅助:写入数据到已格式化NDEF标签
+ */
+ private boolean writeToNdef(Ndef ndef, byte[] data) {
+ try {
+ ndef.connect();
+ ndef.writeNdefMessage(createNdefMessage(data));
+ ndef.close();
+ return true;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "写入已格式化NFC异常", e);
+ try { ndef.close(); } catch (IOException ex) { LogUtils.w(TAG, "关闭Ndef连接异常", ex); }
+ return false;
+ }
+ }
+
+ /**
+ * 内部辅助:格式化标签并写入数据
+ */
+ private boolean writeToFormatable(NdefFormatable formatable, byte[] data) {
+ try {
+ formatable.connect();
+ formatable.format(createNdefMessage(data));
+ formatable.close();
+ return true;
+ } catch (Exception e) {
+ LogUtils.e(TAG, "格式化NFC并写入异常", e);
+ try { formatable.close(); } catch (IOException ex) { LogUtils.w(TAG, "关闭NdefFormatable连接异常", ex); }
+ return false;
+ }
+ }
+
+ /**
+ * 内部辅助:创建标准NDEF消息,适配NFC传输规范
+ */
+ private android.nfc.NdefMessage createNdefMessage(byte[] payload) {
+ android.nfc.NdefRecord record = new android.nfc.NdefRecord(
+ android.nfc.NdefRecord.TNF_MIME_MEDIA,
+ "application/octet-stream".getBytes(),
+ new byte[0],
+ payload
+ );
+ return new android.nfc.NdefMessage(new android.nfc.NdefRecord[]{record});
+ }
+
+ // ==================== 对外公共访问方法(获取缓存/状态,简洁易用) ====================
+ public String getCachePrivateKeyStr() {
+ return mCachePrivateKeyStr;
+ }
+
+ public String getCachePublicKeyStr() {
+ return mCachePublicKeyStr;
+ }
+
+ /**
+ * 校验NFC功能是否可用(硬件支持+已开启)
+ * @return 可用返回true,不可用返回false
+ */
+ public boolean isNfcAvailable() {
+ boolean available = mNfcAdapter != null && mNfcAdapter.isEnabled();
+ LogUtils.d(TAG, "NFC当前可用性:" + available);
+ return available;
+ }
+
+ /**
+ * 清空内存中密钥缓存(如退出登录场景使用)
+ */
+ public void clearCache() {
+ mCachePrivateKeyStr = null;
+ mCachePublicKeyStr = null;
+ LogUtils.d(TAG, "内存密钥缓存已清空");
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ShareLogActivity.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ShareLogActivity.java
new file mode 100644
index 0000000..a9edd48
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/ShareLogActivity.java
@@ -0,0 +1,91 @@
+package cc.winboll.studio.libappbase.utils;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.widget.Toast;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStreamReader;
+
+/**
+ * 分享崩溃日志窗口类
+ * @Author ZhanGSKen
+ * @CreateTime 2026/05/11 22:30:00
+ */
+public class ShareLogActivity extends Activity {
+
+ public static final String TAG = "ShareLogActivity";
+ public static final String EXTRA_CRASH_LOG_FILEPATH = "crash_log_filepath";
+ public static final String EXTRA_CRASH_LOG_SUBJECT = "crash_log_subject";
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Log.d(TAG, "onCreate 进入方法");
+
+ final Intent intent = getIntent();
+ if (intent == null) {
+ Log.e(TAG, "onCreate intent 为空");
+ finish();
+ return;
+ }
+
+ final String crashLogFilePath = intent.getStringExtra(EXTRA_CRASH_LOG_FILEPATH);
+ if (crashLogFilePath == null || crashLogFilePath.isEmpty()) {
+ Log.e(TAG, "onCreate crashLogFilePath 为空");
+ Toast.makeText(this, "日志文件路径无效", Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ final String subject = intent.getStringExtra(EXTRA_CRASH_LOG_SUBJECT);
+ handleShareCrashLog(crashLogFilePath, subject);
+ }
+
+ private void handleShareCrashLog(final String crashLogFilePath, final String subject) {
+ Log.d(TAG, "handleShareCrashLog crashLogFilePath = " + crashLogFilePath);
+
+ final File crashLogFile = new File(crashLogFilePath);
+ if (!crashLogFile.exists()) {
+ Log.e(TAG, "handleShareCrashLog 文件不存在");
+ Toast.makeText(this, "日志文件不存在", Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+
+ BufferedReader reader = null;
+ try {
+ reader = new BufferedReader(new InputStreamReader(new FileInputStream(crashLogFile), "UTF-8"));
+ final StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append("\n");
+ }
+ final String logContent = sb.toString();
+
+ final Intent shareIntent = new Intent(Intent.ACTION_SEND);
+ shareIntent.setType("text/plain");
+ shareIntent.putExtra(Intent.EXTRA_TEXT, logContent);
+ if (subject != null && !subject.isEmpty()) {
+ shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
+ } else {
+ shareIntent.putExtra(Intent.EXTRA_SUBJECT, "崩溃日志");
+ }
+
+ startActivity(Intent.createChooser(shareIntent, "分享日志到"));
+ Log.d(TAG, "handleShareCrashLog 分享成功");
+ } catch (Exception e) {
+ Log.e(TAG, "handleShareCrashLog 异常", e);
+ Toast.makeText(this, "分享失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
+ } finally {
+ if (reader != null) {
+ try { reader.close(); } catch (Exception e) {}
+ }
+ finish();
+ }
+ }
+}
\ No newline at end of file
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/SignGetUtils.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/SignGetUtils.java
new file mode 100644
index 0000000..9868459
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/utils/SignGetUtils.java
@@ -0,0 +1,71 @@
+package cc.winboll.studio.libappbase.utils;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+import android.util.Base64;
+import cc.winboll.studio.libappbase.LogUtils;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/01/20 19:50
+ * @Describe 获取应用签名指纹(SHA1+Base64,直接复制用)
+ */
+public class SignGetUtils {
+ private static final String TAG = "SignGetUtils";
+
+ /**
+ * 一键获取当前应用签名指纹(直接调用,看日志复制结果)
+ */
+ public static void getCurrentAppSign(Context context) {
+ if (context == null) {
+ LogUtils.e(TAG, "context不能为空");
+ return;
+ }
+ try {
+ PackageManager pm = context.getPackageManager();
+ PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
+ Signature[] signatures = pkgInfo.signatures;
+ if (signatures == null || signatures.length == 0) {
+ LogUtils.e(TAG, "未获取到应用签名");
+ return;
+ }
+ // 和APPUtils校验格式完全一致(SHA1+Base64 NO_WRAP)
+ MessageDigest md = MessageDigest.getInstance("SHA1");
+ md.update(signatures[0].toByteArray());
+ String signBase64 = Base64.encodeToString(md.digest(), Base64.NO_WRAP);
+
+ // 关键日志:复制【】里的内容到APPUtils的TARGET_SIGN_FINGERPRINT
+ LogUtils.d(TAG, "当前应用包名:" + context.getPackageName());
+ LogUtils.d(TAG, "当前应用签名指纹(直接复制):【" + signBase64 + "】");
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.e(TAG, "获取签名失败:包名不存在", e);
+ } catch (NoSuchAlgorithmException e) {
+ LogUtils.e(TAG, "获取签名失败:不支持SHA1", e);
+ } catch (Exception e) {
+ LogUtils.e(TAG, "获取签名失败", e);
+ }
+ }
+
+ // 新增:直接返回签名字符串,供对话框调用
+// public static String getSignStr(Context context) {
+// if (context == null) return null;
+// try {
+// PackageManager pm = context.getPackageManager();
+// PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
+// Signature[] signatures = pkgInfo.signatures;
+// if (signatures == null || signatures.length == 0) return null;
+//
+// MessageDigest md = MessageDigest.getInstance("SHA1");
+// md.update(signatures[0].toByteArray());
+// return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
+// } catch (Exception e) {
+// LogUtils.e(TAG, "获取签名字符串失败", e);
+// return null;
+// }
+// }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/views/AboutView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/AboutView.java
new file mode 100644
index 0000000..8d94b53
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/AboutView.java
@@ -0,0 +1,557 @@
+package cc.winboll.studio.libappbase.views;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import cc.winboll.studio.libappbase.GlobalApplication;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.R;
+import cc.winboll.studio.libappbase.ToastUtils;
+import cc.winboll.studio.libappbase.dialogs.DebugHostDialog;
+import cc.winboll.studio.libappbase.dialogs.APPValidationDialog;
+import cc.winboll.studio.libappbase.models.APPInfo;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @CreateTime 2026-01-11 12:23:00
+ * @LastEditTime 2026-01-24 20:50:00
+ * @Describe AboutView 原生实现关于页面,无第三方依赖,适配API30;抽象通用功能控件(邮件/网页跳转),支持调试工具入口动态显隐,集成应用正版校验、调试地址配置弹窗
+ */
+public class AboutView extends LinearLayout {
+ // ===================================== 全局常量 =====================================
+ public static final String TAG = "AboutView";
+ public static final int MSG_APPUPDATE_CHECKED = 0;
+
+ // 固定链接/邮件常量
+ private static final String WINBOLL_OFFICIAL_HOME = "https://www.winboll.cc";
+ private static final String EMAIL_TITLE = "联系WinBoLLStudio";
+ private static final String EMAIL_ADDRESS = "studio@winboll.cc";
+ private static final String EMAIL_TYPE = "message/rfc822";
+
+ // 布局尺寸常量(dp)
+ private static final int PADDING_LARGE = 32;
+ private static final int PADDING_MID = 16;
+ private static final int PADDING_SMALL = 8;
+ private static final int ICON_SIZE = 48;
+ private static final int ITEM_ICON_SIZE = 24;
+
+ // 服务器默认地址常量
+ private static final String SERVER_DEBUG_HOST = "https://yun-preivew.winboll.cc";
+ private static final String SERVER_RELEASE_HOST = "https://yun.winboll.cc";
+
+ // ===================================== 核心成员属性 =====================================
+ // 上下文与业务实体
+ private Context mContext;
+ private APPInfo mAPPInfo;
+ private OnRequestDevUserInfoAutofillListener mOnRequestDevUserInfoAutofillListener;
+
+ // 应用基础信息
+ private String mszAppName = "";
+ private String mszAppVersionName = "";
+ private String mszAppDescription = "";
+ private String mszHomePage = "";
+ private String mszGitea = "";
+ private String mszAppGitName = "";
+ private String mszAppAPKName = "";
+ private String mszAppAPKFolderName = "";
+ private String mszCurrentAppPackageName = "";
+ private String mszReleaseAPKName = "";
+ private volatile String mszNewestAppPackageName = "";
+ private String mszWinBoLLServerHost = "";
+ private int mnAppIcon = 0;
+ private boolean mIsAddDebugTools = false;
+
+ // 调试视图
+ private EditText metDevUserName;
+ private EditText metDevUserPassword;
+
+ // ===================================== 页面视图控件 =====================================
+ private DebugSwitchImageView ivAppIcon;
+ private TextView tvAppNameVersion;
+ private TextView tvAppDesc;
+ private LinearLayout llFunctionContainer;
+ private ImageButton ibSebugStepOver;
+ private ImageButton ibSigngetDialog;
+ private ImageButton ibWinBoLLHostDialog;
+
+ // ===================================== 构造方法(按参数从少到多排序) =====================================
+ public AboutView(Context context) {
+ super(context);
+ LogUtils.d(TAG, "AboutView(Context):代码创建视图,执行默认初始化");
+ this.mContext = context;
+ initDefaultParams();
+ initViewFromXml();
+ }
+
+// public AboutView(Context context, APPInfo appInfo) {
+// super(context);
+// LogUtils.d(TAG, "AboutView(Context,APPInfo):传入应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
+// this.mContext = context;
+// this.mAPPInfo = appInfo;
+// initViewFromXml();
+// initAll();
+// }
+
+ public AboutView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ LogUtils.d(TAG, "AboutView(Context,AttributeSet):XML布局引用,执行默认初始化");
+ this.mContext = context;
+ initDefaultParams();
+ initViewFromXml();
+ }
+
+ public AboutView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ LogUtils.d(TAG, "AboutView(Context,AttributeSet,int):XML布局+样式配置,defStyleAttr=" + defStyleAttr);
+ this.mContext = context;
+ initDefaultParams();
+ initViewFromXml();
+ }
+
+ // ===================================== 对外公开方法 =====================================
+ /**
+ * 一站式初始化所有关于页逻辑,包含参数、应用信息、页面视图全流程
+ */
+ public void initAll() {
+ LogUtils.d(TAG, "initAll():开始一站式初始化,APPInfo是否为空=" + (mAPPInfo == null));
+ if (mAPPInfo == null) {
+ LogUtils.w(TAG, "initAll():初始化终止,APPInfo为null");
+ return;
+ }
+ initDefaultParams();
+ initAPPBaseInfo();
+ initAPPVersionInfo();
+ initServerConfig();
+ initAPPLinkInfo();
+ initReleaseAPKInfo();
+ initAboutPageView();
+ LogUtils.d(TAG, "initAll():所有初始化流程执行完成");
+ }
+
+ /**
+ * 重置应用信息并重新初始化页面,支持动态更新关于页内容
+ * @param appInfo 新的应用信息实体
+ */
+// public void setAPPInfoAndInit(APPInfo appInfo) {
+// LogUtils.d(TAG, "setAPPInfoAndInit():重置应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
+// this.mAPPInfo = appInfo;
+// if (llFunctionContainer != null) llFunctionContainer.removeAllViews();
+// initAll();
+// LogUtils.d(TAG, "setAPPInfoAndInit():应用信息重置+页面重构完成");
+// }
+
+ /**
+ * 设置应用信息,兼容旧调用逻辑,设置后自动重构页面
+ * @param appInfo 应用核心信息实体
+ */
+ public void setAPPInfo(APPInfo appInfo) {
+ LogUtils.d(TAG, "setAPPInfo():设置应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
+ this.mAPPInfo = appInfo;
+ if (llFunctionContainer != null) llFunctionContainer.removeAllViews();
+ initAll();
+ }
+
+ /**
+ * 设置调试信息自动填充监听,供调试场景回调使用
+ * @param l 监听回调接口实现
+ */
+ public void setOnRequestDevUserInfoAutofillListener(OnRequestDevUserInfoAutofillListener l) {
+ LogUtils.d(TAG, "setOnRequestDevUserInfoAutofillListener():设置调试信息填充监听完成");
+ this.mOnRequestDevUserInfoAutofillListener = l;
+ }
+
+ // ===================================== 内部初始化方法 =====================================
+ /**
+ * 初始化默认兜底参数,防止空指针,为后续初始化做基础铺垫
+ */
+ private void initDefaultParams() {
+ LogUtils.d(TAG, "initDefaultParams():开始初始化默认参数");
+ mszWinBoLLServerHost = GlobalApplication.isDebugging() ? SERVER_DEBUG_HOST : SERVER_RELEASE_HOST;
+ mnAppIcon = (mnAppIcon == 0) ? R.drawable.ic_winboll : mnAppIcon;
+ mIsAddDebugTools = false;
+ LogUtils.d(TAG, "initDefaultParams():默认参数初始化完成,服务器地址=" + mszWinBoLLServerHost + ",应用图标ID=" + mnAppIcon);
+ }
+
+ /**
+ * 加载XML布局并绑定所有视图控件,初始化按钮点击事件
+ */
+ private void initViewFromXml() {
+ LogUtils.d(TAG, "initViewFromXml():开始加载布局并绑定控件");
+ View.inflate(mContext, R.layout.layout_about_view, this);
+ // 基础控件绑定
+ ivAppIcon = findViewById(R.id.iv_app_icon);
+ tvAppNameVersion = findViewById(R.id.tv_app_name_version);
+ tvAppDesc = findViewById(R.id.tv_app_desc);
+ llFunctionContainer = findViewById(R.id.ll_function_container);
+ // 功能按钮绑定
+ ibSebugStepOver = findViewById(R.id.ib_debug_step_over);
+ ibSigngetDialog = findViewById(R.id.ib_signgetdialog);
+ ibWinBoLLHostDialog = findViewById(R.id.ib_winbollhostdialog);
+
+ // 调试按钮统一只在调试模式显示
+ ibWinBoLLHostDialog.setVisibility(GlobalApplication.isDebugging() ? View.VISIBLE : View.GONE);
+ //ibSigngetDialog.setVisibility(GlobalApplication.isDebugging() ? View.VISIBLE : View.GONE);
+ ibSebugStepOver.setVisibility(GlobalApplication.isDebugging() ? View.VISIBLE : View.GONE);
+
+ // 绑定按钮点击事件
+ setBtnClickListener();
+ LogUtils.d(TAG, "initViewFromXml():布局加载+控件绑定+事件初始化完成");
+ }
+
+ /**
+ * 从APPInfo实体读取应用基础核心配置,赋值到本地属性
+ */
+ private void initAPPBaseInfo() {
+ LogUtils.d(TAG, "initAPPBaseInfo():开始读取APPInfo基础配置");
+ if (mAPPInfo == null) {
+ LogUtils.w(TAG, "initAPPBaseInfo():跳过执行,APPInfo为null");
+ return;
+ }
+ mszAppName = mAPPInfo.getAppName() == null ? "" : mAPPInfo.getAppName();
+ mszAppAPKFolderName = mAPPInfo.getAppAPKFolderName() == null ? "" : mAPPInfo.getAppAPKFolderName();
+ mszAppAPKName = mAPPInfo.getAppAPKName() == null ? "" : mAPPInfo.getAppAPKName();
+ mszAppGitName = mAPPInfo.getAppGitName() == null ? "" : mAPPInfo.getAppGitName();
+ mszAppDescription = mAPPInfo.getAppDescription() == null ? "" : mAPPInfo.getAppDescription();
+ mnAppIcon = (mAPPInfo.getAppIcon() != 0) ? mAPPInfo.getAppIcon() : mnAppIcon;
+ mIsAddDebugTools = mAPPInfo.isAddDebugTools();
+ LogUtils.d(TAG, "initAPPBaseInfo():基础配置读取完成,应用名=" + mszAppName + ",调试开关=" + mIsAddDebugTools);
+ }
+
+ /**
+ * 从包管理中获取当前应用版本号,初始化版本相关信息
+ */
+ private void initAPPVersionInfo() {
+ LogUtils.d(TAG, "initAPPVersionInfo():开始初始化应用版本信息");
+ try {
+ mszAppVersionName = mContext.getPackageManager().getPackageInfo(mContext.getPackageName(), 0).versionName;
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.e(TAG, "initAPPVersionInfo():获取版本号失败,默认赋值unknown", e);
+ mszAppVersionName = "unknown";
+ }
+ mszCurrentAppPackageName = String.format("%s_%s.apk", mszAppVersionName, mszAppVersionName);
+ LogUtils.d(TAG, "initAPPVersionInfo():版本信息初始化完成,版本号=" + mszAppVersionName + ",当前APK名=" + mszCurrentAppPackageName);
+ }
+
+ /**
+ * 初始化服务器相关配置,预留扩展接口
+ */
+ private void initServerConfig() {
+ LogUtils.d(TAG, "initServerConfig():服务器配置初始化,预留扩展接口");
+ }
+
+ /**
+ * 初始化应用相关链接(主页+Git源码地址),根据分支配置动态拼接Git地址
+ */
+ private void initAPPLinkInfo() {
+ LogUtils.d(TAG, "initAPPLinkInfo():开始初始化应用链接信息");
+ if (mAPPInfo == null) {
+ LogUtils.w(TAG, "initAPPLinkInfo():跳过执行,APPInfo为null");
+ return;
+ }
+ mszHomePage = mAPPInfo.getAppHomePage() == null ? "" : mAPPInfo.getAppHomePage();
+ // 拼接Git地址,兼容无分支配置场景
+ if (mAPPInfo.getAppGitAPPBranch() == null || mAPPInfo.getAppGitAPPBranch().trim().isEmpty()) {
+ mszGitea = String.format("https://gitea.winboll.cc/%s/%s", mAPPInfo.getAppGitOwner(), mszAppGitName);
+ } else {
+ mszGitea = String.format("https://gitea.winboll.cc/%s/%s/src/branch/%s/%s",
+ mAPPInfo.getAppGitOwner(), mszAppGitName,
+ mAPPInfo.getAppGitAPPBranch(), mAPPInfo.getAppGitAPPSubProjectFolder());
+ }
+ LogUtils.d(TAG, "initAPPLinkInfo():链接信息初始化完成,应用主页=" + mszHomePage + ",Git地址=" + mszGitea);
+ }
+
+ /**
+ * 初始化正式版APK信息,去除beta后缀适配正式包命名规范
+ */
+ private void initReleaseAPKInfo() {
+ LogUtils.d(TAG, "initReleaseAPKInfo():开始初始化正式版APK信息");
+ String szReleaseAppVersionName = "unknown";
+ try {
+ String szSubBetaSuffix = subBetaSuffix(mContext.getPackageName());
+ szReleaseAppVersionName = mContext.getPackageManager().getPackageInfo(szSubBetaSuffix, 0).versionName;
+ } catch (PackageManager.NameNotFoundException e) {
+ LogUtils.e(TAG, "initReleaseAPKInfo():获取正式版版本号失败", e);
+ }
+ mszReleaseAPKName = String.format("%s_%s.apk", mszAppAPKName, szReleaseAppVersionName);
+ LogUtils.d(TAG, "initReleaseAPKInfo():正式版APK信息初始化完成,APK名=" + mszReleaseAPKName);
+ }
+
+ /**
+ * 核心视图组装:赋值基础信息到控件,添加通用功能项到容器
+ */
+ private void initAboutPageView() {
+ LogUtils.d(TAG, "initAboutPageView():开始组装关于页视图");
+ // 赋值基础信息
+ ivAppIcon.setImageResource(mnAppIcon);
+ tvAppNameVersion.setText(String.format("%s %s", mszAppName, mszAppVersionName));
+ if (mszAppDescription.isEmpty()) {
+ tvAppDesc.setVisibility(GONE);
+ } else {
+ tvAppDesc.setVisibility(VISIBLE);
+ tvAppDesc.setText(mszAppDescription);
+ }
+ // 添加通用功能项
+ addFunctionView(new WebJumpFunctionItemView(mContext, "WinBoLL 主页", WINBOLL_OFFICIAL_HOME, R.drawable.ic_winboll));
+ addFunctionView(new EmailFunctionItemView(mContext, "联系邮箱", "WinBoLLStudio", R.drawable.ic_winboll));
+ if (!mszHomePage.isEmpty()) {
+ addFunctionView(new WebJumpFunctionItemView(mContext, "应用APK下载地址", mszHomePage, R.drawable.ic_winboll));
+ }
+ if (!mszGitea.isEmpty()) {
+ addFunctionView(new WebJumpFunctionItemView(mContext, "应用Git源码地址", mszGitea, R.drawable.ic_winboll));
+ }
+ LogUtils.d(TAG, "initAboutPageView():视图组装完成,功能项加载完毕");
+ }
+
+ // ===================================== 内部工具/事件方法 =====================================
+ /**
+ * 绑定功能按钮点击事件,处理正版校验、调试地址配置弹窗唤起
+ */
+ private void setBtnClickListener() {
+ LogUtils.d(TAG, "setBtnClickListener():开始绑定功能按钮点击事件");
+ // 取消调试状态按钮
+ ibSebugStepOver.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ LogUtils.d(TAG, "ibSebugStepOver onClick:取消调试状态按钮已点击");
+ GlobalApplication.setIsDebugging(false);
+ GlobalApplication.saveDebugStatus(GlobalApplication.getInstance());
+ ToastUtils.show("已取消调试状态,重启应用可生效。");
+ }
+ });
+ // 正版校验弹窗
+ ibSigngetDialog.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ LogUtils.d(TAG, "ibSigngetDialog onClick:唤起应用正版校验弹窗");
+ new APPValidationDialog(mContext, mszAppName, mszAppVersionName).show();
+ }
+ });
+ // 调试地址配置弹窗
+ ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ LogUtils.d(TAG, "ibWinBoLLHostDialog onClick:唤起调试地址配置弹窗");
+ new DebugHostDialog(mContext).show();
+ }
+ });
+ LogUtils.d(TAG, "setBtnClickListener():功能按钮点击事件绑定完成");
+ }
+
+ /**
+ * 添加功能项视图到容器,统一设置间距
+ * @param view 功能项视图
+ */
+ private void addFunctionView(View view) {
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
+ params.topMargin = 0;
+ llFunctionContainer.addView(view, params);
+ }
+
+ /**
+ * dp转px工具方法,适配不同屏幕密度,保证布局一致性
+ * @param dpValue dp单位尺寸
+ * @return 转换后的px单位尺寸
+ */
+ private int dp2px(int dpValue) {
+ float density = mContext.getResources().getDisplayMetrics().density;
+ return (int) (dpValue * density + 0.5f);
+ }
+
+ /**
+ * 去除包名beta后缀,适配正式版包名规范,静态方法支持外部调用
+ * @param input 原始包名
+ * @return 去除beta后缀后的正式包名
+ */
+ public static String subBetaSuffix(String input) {
+ LogUtils.d(TAG, "subBetaSuffix():执行包名beta后缀去除,原始包名=" + input);
+ if (input != null && input.endsWith(".beta")) {
+ String result = input.substring(0, input.length() - ".beta".length());
+ LogUtils.d(TAG, "subBetaSuffix():处理成功,正式包名=" + result);
+ return result;
+ }
+ LogUtils.d(TAG, "subBetaSuffix():无需处理,包名不含beta后缀");
+ return input == null ? "" : input;
+ }
+
+ // ===================================== 内部抽象通用功能项基类 =====================================
+ /**
+ * 通用功能项基类,统一样式、布局、视图构建,减少冗余代码
+ */
+ private abstract class BaseFunctionItemView extends LinearLayout implements OnClickListener {
+ protected Context mItemContext;
+ protected String mTitle;
+ protected String mContent;
+ protected int mIconRes;
+
+ public BaseFunctionItemView(Context context, String title, String content, int iconRes) {
+ super(context);
+ this.mItemContext = context;
+ this.mTitle = title;
+ this.mContent = content;
+ this.mIconRes = iconRes;
+ initItemLayout();
+ initItemViews();
+ setOnClickListener(this);
+ }
+
+ /**
+ * 统一初始化功能项布局属性
+ */
+ private void initItemLayout() {
+ setOrientation(HORIZONTAL);
+ setGravity(Gravity.CENTER_VERTICAL);
+ setPadding(dp2px(PADDING_MID), dp2px(PADDING_SMALL), dp2px(PADDING_MID), dp2px(PADDING_SMALL));
+ setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+ setClickable(true);
+ setBackground(create_item_background());
+ }
+
+ /**
+ * 创建带1像素边框的背景drawable
+ */
+ private android.graphics.drawable.Drawable create_item_background() {
+ android.graphics.drawable.GradientDrawable drawable = new android.graphics.drawable.GradientDrawable();
+ drawable.setStroke(1, mItemContext.getResources().getColor(R.color.gray_300));
+ drawable.setCornerRadius(4);
+ boolean isNightMode = (mItemContext.getResources().getConfiguration().uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES;
+ drawable.setColor(isNightMode ? mItemContext.getResources().getColor(R.color.gray_800) : mItemContext.getResources().getColor(android.R.color.white));
+ return drawable;
+ }
+
+ /**
+ * 统一构建功能项视图(左侧图标+右侧标题/内容)
+ */
+ private void initItemViews() {
+ // 左侧图标
+ if (mIconRes != 0) {
+ ImageView ivIcon = new ImageView(mItemContext);
+ LayoutParams iconParams = new LayoutParams(dp2px(ITEM_ICON_SIZE), dp2px(ITEM_ICON_SIZE));
+ iconParams.rightMargin = dp2px(PADDING_SMALL);
+ ivIcon.setLayoutParams(iconParams);
+ ivIcon.setImageResource(mIconRes);
+ addView(ivIcon);
+ }
+ // 右侧文本容器
+ LinearLayout llText = new LinearLayout(mItemContext);
+ llText.setOrientation(VERTICAL);
+ llText.setLayoutParams(new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1.0f));
+ addView(llText);
+ // 标题
+ TextView tvTitle = new TextView(mItemContext);
+ tvTitle.setText(mTitle);
+ tvTitle.setTextSize(16);
+ boolean isNightMode = (mItemContext.getResources().getConfiguration().uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES;
+ tvTitle.setTextColor(isNightMode ? mItemContext.getResources().getColor(R.color.gray_500) : mItemContext.getResources().getColor(R.color.gray_900));
+ llText.addView(tvTitle);
+ // 内容
+ TextView tvContent = new TextView(mItemContext);
+ tvContent.setText(mContent);
+ tvContent.setTextSize(14);
+ tvContent.setTextColor(getContentTextColor());
+ tvContent.setPadding(0, dp2px(PADDING_SMALL), 0, 0);
+ llText.addView(tvContent);
+ }
+
+ /**
+ * 子类抽象方法:指定内容文本颜色
+ * @return 颜色值
+ */
+ protected abstract int getContentTextColor();
+ }
+
+ // ===================================== 内部邮件功能项子类 =====================================
+ /**
+ * 邮件类功能控件,实现专属邮件唤起逻辑,双方案兼容(纯邮件客户端/通用邮件应用)
+ */
+ private class EmailFunctionItemView extends BaseFunctionItemView {
+ public EmailFunctionItemView(Context context, String title, String content, int iconRes) {
+ super(context, title, content, iconRes);
+ }
+
+ @Override
+ protected int getContentTextColor() {
+ return mItemContext.getResources().getColor(R.color.blue_normal);
+ }
+
+ @Override
+ public void onClick(View v) {
+ LogUtils.d(TAG, "EmailFunctionItemView onClick:触发邮件唤起逻辑");
+ // 方案1:纯邮件客户端唤起
+ Intent emailIntent = new Intent(Intent.ACTION_SENDTO);
+ emailIntent.setData(Uri.parse("mailto:" + EMAIL_ADDRESS));
+ emailIntent.putExtra(Intent.EXTRA_SUBJECT, EMAIL_TITLE);
+ emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (emailIntent.resolveActivity(mItemContext.getPackageManager()) != null) {
+ mItemContext.startActivity(emailIntent);
+ LogUtils.d(TAG, "EmailFunctionItemView:纯邮件客户端唤起成功");
+ return;
+ }
+ // 方案2:通用邮件应用兜底
+ Intent fallbackIntent = new Intent(Intent.ACTION_SEND);
+ fallbackIntent.setType(EMAIL_TYPE);
+ fallbackIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{EMAIL_ADDRESS});
+ fallbackIntent.putExtra(Intent.EXTRA_SUBJECT, EMAIL_TITLE);
+ fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (fallbackIntent.resolveActivity(mItemContext.getPackageManager()) != null) {
+ mItemContext.startActivity(fallbackIntent);
+ LogUtils.d(TAG, "EmailFunctionItemView:通用邮件应用唤起成功");
+ } else {
+ ToastUtils.show("未找到可发送邮件的应用");
+ LogUtils.w(TAG, "EmailFunctionItemView:邮件唤起失败,无可用邮件应用");
+ }
+ }
+ }
+
+ // ===================================== 内部网页跳转功能项子类 =====================================
+ /**
+ * 网页跳转类功能控件,实现专属网页唤起逻辑,包含空地址校验、异常捕获
+ */
+ private class WebJumpFunctionItemView extends BaseFunctionItemView {
+ public WebJumpFunctionItemView(Context context, String title, String content, int iconRes) {
+ super(context, title, content, iconRes);
+ }
+
+ @Override
+ protected int getContentTextColor() {
+ return mItemContext.getResources().getColor(R.color.blue_normal);
+ }
+
+ @Override
+ public void onClick(View v) {
+ LogUtils.d(TAG, "WebJumpFunctionItemView onClick:触发网页跳转,地址=" + mContent);
+ if (mContent.isEmpty()) {
+ ToastUtils.show("跳转地址为空");
+ LogUtils.w(TAG, "WebJumpFunctionItemView:网页跳转失败,地址为空");
+ return;
+ }
+ try {
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(mContent));
+ browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ mItemContext.startActivity(browserIntent);
+ LogUtils.d(TAG, "WebJumpFunctionItemView:网页跳转成功");
+ } catch (Exception e) {
+ LogUtils.e(TAG, "WebJumpFunctionItemView:网页跳转失败", e);
+ ToastUtils.show("链接无法打开");
+ }
+ }
+ }
+
+ // ===================================== 内部回调接口 =====================================
+ /**
+ * 调试信息自动填充回调接口
+ */
+ public interface OnRequestDevUserInfoAutofillListener {
+ void requestAutofill(EditText etDevUserName, EditText etDevUserPassword);
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/views/DebugSwitchImageView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/DebugSwitchImageView.java
new file mode 100644
index 0000000..fa353f1
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/DebugSwitchImageView.java
@@ -0,0 +1,61 @@
+package cc.winboll.studio.libappbase.views;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.Toast;
+import cc.winboll.studio.libappbase.GlobalApplication;
+
+/**
+ * @Author 豆包&ZhanGSKen
+ * @Date 2026/04/06 19:32
+ * @Describe 具有调试模式切换功能的应用Logo控件,连续点击10次弹出提示
+ */
+public class DebugSwitchImageView extends ImageView {
+
+ public static final String TAG = "DebugSwitchImageView";
+
+ // 连续点击计数
+ private int mClickCount = 0;
+ // 目标点击次数
+ private static final int TARGET_CLICK_COUNT = 10;
+
+ public DebugSwitchImageView(Context context) {
+ super(context);
+ init();
+ }
+
+ public DebugSwitchImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public DebugSwitchImageView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public DebugSwitchImageView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ private void init() {
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mClickCount++;
+ if (mClickCount == TARGET_CLICK_COUNT) {
+ // 达到10次,弹出Toast
+ Toast.makeText(getContext(), "连续点击已达到10次,现在开启应用调试功能。", Toast.LENGTH_SHORT).show();
+ GlobalApplication.setIsDebugging(true);
+ GlobalApplication.saveDebugStatus(GlobalApplication.getInstance());
+ // 重置计数,可再次触发
+ mClickCount = 0;
+ }
+ }
+ });
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/views/HorizontalListView.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/HorizontalListView.java
new file mode 100644
index 0000000..4e41357
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/views/HorizontalListView.java
@@ -0,0 +1,129 @@
+package cc.winboll.studio.libappbase.views;
+
+/**
+ * @Author ZhanGSKen@AliYun.Com
+ * @Date 2025/03/12 12:29:01
+ * @Describe 水平布局的 ListView
+ */
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ListView;
+import android.widget.Scroller;
+import cc.winboll.studio.libappbase.LogUtils;
+
+public class HorizontalListView extends ListView {
+ public static final String TAG = "HorizontalListView";
+ private int verticalOffset = 0;
+ private Scroller scroller;
+ private int totalWidth;
+
+ public HorizontalListView(Context context) {
+ super(context);
+ init();
+ }
+
+ public HorizontalListView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public HorizontalListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init();
+ }
+
+ private void init() {
+ scroller = new Scroller(getContext());
+ setHorizontalScrollBarEnabled(true);
+ setVerticalScrollBarEnabled(false);
+ }
+
+ public void setVerticalOffset(int verticalOffset) {
+ this.verticalOffset = verticalOffset;
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ int childCount = getChildCount();
+ int left = getPaddingLeft();
+ int viewHeight = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
+ totalWidth = left;
+
+ for (int i = 0; i < childCount; i++) {
+ View child = getChildAt(i);
+ int width = child.getMeasuredWidth();
+ int height = child.getMeasuredHeight();
+ child.layout(left, verticalOffset, left + width, verticalOffset + height);
+ left += width;
+ }
+ totalWidth = left + getPaddingRight();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
+ int newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
+ super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec);
+ }
+
+ @Override
+ public void computeScroll() {
+ if (scroller.computeScrollOffset()) {
+ scrollTo(scroller.getCurrX(), scroller.getCurrY());
+ postInvalidate();
+ }
+ }
+
+ public void smoothScrollTo(int x, int y) {
+ int dx = x - getScrollX();
+ int dy = y - getScrollY();
+ scroller.startScroll(getScrollX(), getScrollY(), dx, dy, 300); // 300ms平滑动画
+ invalidate();
+ }
+
+ @Override
+ public int computeHorizontalScrollRange() {
+ return totalWidth;
+ }
+
+ @Override
+ public int computeHorizontalScrollOffset() {
+ return getScrollX();
+ }
+
+ @Override
+ public int computeHorizontalScrollExtent() {
+ return getWidth();
+ }
+
+ public void scrollToItem(int position) {
+ if (position < 0 || position >= getChildCount()) {
+ LogUtils.d(TAG, "无效的position: " + position);
+ return;
+ }
+
+ View targetView = getChildAt(position);
+ int targetLeft = targetView.getLeft();
+ int scrollX = targetLeft - getPaddingLeft();
+
+ // 修正最大滚动范围计算
+ int maxScrollX = totalWidth;
+ scrollX = Math.max(0, Math.min(scrollX, maxScrollX));
+
+ // 强制重新布局和绘制
+ requestLayout();
+ invalidateViews();
+ smoothScrollTo(scrollX, 0);
+ LogUtils.d(TAG, String.format("滚动到position: %d, scrollX: %d computeHorizontalScrollRange() %d", position, scrollX, computeHorizontalScrollRange()));
+ }
+
+ public void resetScrollToStart() {
+ // 强制重新布局和绘制
+ requestLayout();
+ invalidateViews();
+ smoothScrollTo(0, 0);
+ }
+}
+
diff --git a/libappbase/src/main/java/cc/winboll/studio/libappbase/widget/LogTagSpinner.java b/libappbase/src/main/java/cc/winboll/studio/libappbase/widget/LogTagSpinner.java
new file mode 100644
index 0000000..32609ec
--- /dev/null
+++ b/libappbase/src/main/java/cc/winboll/studio/libappbase/widget/LogTagSpinner.java
@@ -0,0 +1,252 @@
+package cc.winboll.studio.libappbase.widget;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.text.TextUtils;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Spinner;
+import android.widget.TextView;
+import cc.winboll.studio.libappbase.LogUtils;
+import cc.winboll.studio.libappbase.R;
+
+/**
+ * @Author ZhanGSKen&豆包大模型
+ * @Date 2025/12/15 13:51
+ * @Describe 纯原生 LogTag 专属 Spinner(无 androidx 依赖)
+ * 核心特性:1. 继承原生 Spinner,适配全 Android 版本;2. dimen 统一配置所有尺寸;3. 文字大小支持 dp 单位;4. 简化外部初始化
+ */
+public class LogTagSpinner extends Spinner {
+ public static final String TAG = "LogTagSpinner";
+
+ Context mContext;
+ // 尺寸缓存(dimen 解析后转 px,避免重复计算)
+ private int mSpinnerWidth; // 控件自身框度(px)
+ private int mSpinnerHeight; // 控件自身高度(px)
+ private int mItemWidth; // 下拉项单个高度(px)
+ private int mItemHeight; // 下拉项单个高度(px)
+ private float mTextSizePx; // 文字大小(px,dp 转译后)
+ private int mTextPadding; // 文字左右内边距(px)
+
+ // 内置适配器(外部无需关心内部实现)
+ private ArrayAdapter mLogTagAdapter;
+
+
+ // -------------------------- 构造方法(原生 Spinner 必重写 3 个)--------------------------
+ public LogTagSpinner(Context context) {
+ super(context);
+ initCoreLogic(context);
+ }
+
+ public LogTagSpinner(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initCoreLogic(context);
+ }
+
+ public LogTagSpinner(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initCoreLogic(context);
+ }
+
+ // -------------------------- 核心初始化(解析资源 + 配置样式 + 初始化适配器)--------------------------
+ private void initCoreLogic(Context context) {
+ this.mContext = context;
+ // 1. 读取 dimen 资源(dp 转 px,核心适配)
+ parseDimenResources();
+ // 2. 设置 Spinner 自身基础样式(高度、背景)
+ configSelfStyle();
+ // 3. 初始化适配器(统一选中项+下拉项样式)
+ initCustomAdapter();
+ }
+
+
+ /**
+ * 步骤 1:解析 dimen 资源,所有 dp 单位转为 px(跨设备视觉一致)
+ */
+ private void parseDimenResources() {
+ Context context = this.mContext;
+ // 控件自身宽度(dp → px)
+ mSpinnerWidth = context.getResources().getDimensionPixelSize(R.dimen.log_spinner_width);
+ // 控件自身高度(dp → px)
+ mSpinnerHeight = context.getResources().getDimensionPixelSize(R.dimen.log_spinner_height);
+ // 下拉项宽度(dp → px)
+ mItemWidth = context.getResources().getDimensionPixelSize(R.dimen.log_spinner_item_width);
+ // 下拉项高度(dp → px)
+ mItemHeight = context.getResources().getDimensionPixelSize(R.dimen.log_spinner_item_height);
+ // 文字大小(dp → px,核心:用 getDimensionPixelSize 确保 dp 精准转译)
+ mTextSizePx = context.getResources().getDimensionPixelSize(R.dimen.log_spinner_text_size);
+ // 文字内边距(dp → px)
+ mTextPadding = context.getResources().getDimensionPixelSize(R.dimen.log_spinner_text_padding);
+
+ LogUtils.d("LogTagSpinner", "dimen 解析完成:高度=" + mSpinnerHeight + "px,文字大小=" + mTextSizePx + "px");
+ }
+
+
+ /**
+ * 步骤 2:配置 Spinner 自身样式(覆盖布局属性,统一控制)
+ */
+ private void configSelfStyle() {
+ // 动态设置控件高度(布局中 layout_height 设 wrap_content 即可,这里统一控制)
+ ViewGroup.LayoutParams layoutParams = getLayoutParams();
+ if (layoutParams != null) {
+ layoutParams.width = mSpinnerWidth;
+ layoutParams.height = mSpinnerHeight;
+ } else {
+ // 代码创建控件时,手动初始化布局参数
+ layoutParams = new ViewGroup.LayoutParams(
+ mSpinnerWidth,
+ mSpinnerHeight
+ );
+ }
+ setLayoutParams(layoutParams);
+
+ // 统一背景色(外部可通过 setBackground 手动覆盖)
+ setBackgroundColor(this.mContext.getColor(R.color.btn_gray_normal));
+ }
+
+
+ /**
+ * 步骤 3:初始化自定义适配器,统一选中项+下拉项样式
+ */
+ private void initCustomAdapter() {
+ // 用原生系统布局(避免自定义布局,减少依赖),后续重写样式
+ mLogTagAdapter = new ArrayAdapter(this.mContext, android.R.layout.simple_spinner_item) {
+ // 重写:控制「已选中项」的样式(文字大小、高度、内边距)
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ TextView itemTv = (TextView) super.getView(position, convertView, parent);
+ setItemUniformStyle(itemTv);
+ return itemTv;
+ }
+
+ // 重写:控制「下拉列表项」的样式(必须重写,否则下拉项样式不生效)
+ @Override
+ public View getDropDownView(int position, View convertView, ViewGroup parent) {
+ TextView itemTv = (TextView) super.getDropDownView(position, convertView, parent);
+ setItemUniformStyle(itemTv);
+ return itemTv;
+ }
+ };
+
+ // 绑定下拉项布局(原生系统布局,确保低版本兼容)
+ mLogTagAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ // 设置适配器到 Spinner
+ setAdapter(mLogTagAdapter);
+ }
+
+
+ /**
+ * 通用方法:统一设置列表项(选中项/下拉项)的样式
+ */
+ private void setItemUniformStyle(TextView itemTv) {
+ if (itemTv == null) return;
+
+ // 1. 文字大小(核心:按 px 赋值,dp 转译后无二次换算,精准适配)
+ itemTv.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSizePx);
+ // 2. 列表项高度(固定高度,避免文字多少导致高度不一致)
+ itemTv.setWidth(mItemWidth);
+ itemTv.setHeight(mItemHeight);
+ // 3. 内边距(左右留白,优化排版,避免文字贴边)
+ itemTv.setPadding(mTextPadding, 0, mTextPadding, 0);
+ // 4. 文字对齐(垂直居中+靠左,符合常规 UI 设计)
+ //itemTv.setGravity(View.GRAVITY_CENTER_VERTICAL | View.GRAVITY_START);
+// 5. 文字颜色(使用主题属性 ?attr/toolbarTextColor)
+ TypedArray ta = mContext.obtainStyledAttributes(new int[] { R.attr.toolbarTextColor });
+ int toolbarTextColor = ta.getColor(0, mContext.getResources().getColor(R.color.white));
+ ta.recycle();
+ itemTv.setTextColor(toolbarTextColor);
+ itemTv.setBackgroundColor(this.mContext.getResources().getColor(R.color.btn_gray_normal));
+ // 6. 文字溢出处理(最多 2 行,超出省略,避免长标签换行过多)
+ itemTv.setSingleLine(false);
+ itemTv.setMaxLines(2);
+ itemTv.setEllipsize(TextUtils.TruncateAt.END);
+ }
+
+
+ // -------------------------- 外部调用 API(极简用法,无需关心内部逻辑)--------------------------
+ /**
+ * 填充日志标签数据(外部核心调用,一行代码搞定)
+ * @param logTagArray 日志标签数组(如:{"TAG_MAIN", "TAG_NET", "TAG_DB"})
+ */
+ public void setLogTagData(String[] logTagArray) {
+ if (mLogTagAdapter == null || logTagArray == null || logTagArray.length == 0) {
+ LogUtils.w("LogTagSpinner", "填充数据失败:适配器为空或数据无效");
+ return;
+ }
+ // 清空旧数据,添加新数据,刷新适配器
+ mLogTagAdapter.clear();
+ mLogTagAdapter.addAll(logTagArray);
+ mLogTagAdapter.notifyDataSetChanged();
+ }
+
+ /**
+ * 设置默认选中的标签(索引从 0 开始)
+ * @param defaultIndex 默认选中索引(需在 setLogTagData 之后调用)
+ */
+ public void setDefaultSelectedTag(int defaultIndex) {
+ if (mLogTagAdapter == null) return;
+ // 索引合法性校验,避免数组越界
+ if (defaultIndex >= 0 && defaultIndex < mLogTagAdapter.getCount()) {
+ setSelection(defaultIndex);
+ LogUtils.d("LogTagSpinner", "默认选中标签:" + mLogTagAdapter.getItem(defaultIndex));
+ } else {
+ LogUtils.w("LogTagSpinner", "默认选中索引无效:" + defaultIndex);
+ }
+ }
+
+ /**
+ * 获取当前选中的日志标签(外部业务逻辑调用)
+ * @return 当前选中的标签文字(无选中时返回空字符串)
+ */
+ public String getCurrentSelectedTag() {
+ Object selectedItem = getSelectedItem();
+ return selectedItem != null ? selectedItem.toString() : "";
+ }
+
+
+ // -------------------------- 优化扩展(可选,提升稳定性)--------------------------
+ /**
+ * 视图附着到窗口时,确保默认有数据(避免空指针)
+ */
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ if (mLogTagAdapter != null && mLogTagAdapter.getCount() == 0) {
+ setLogTagData(new String[]{"默认标签"});
+ }
+ }
+
+ /**
+ * 外部扩展:动态修改文字大小(dp 单位)
+ * @param textSizeDp 目标文字大小(dp)
+ */
+ public void updateTextSize(int textSizeDp) {
+ mTextSizePx = this.mContext.getResources().getDimensionPixelSize(textSizeDp);
+ // 刷新所有列表项样式
+ if (mLogTagAdapter != null) {
+ mLogTagAdapter.notifyDataSetChanged();
+ }
+ }
+
+ /**
+ * 外部扩展:动态修改文字颜色
+ * @param textColorRes 文字颜色资源 ID(如 R.color.red)
+ */
+ public void updateTextColor(int textColorRes) {
+ int textColor = this.mContext.getResources().getColor(textColorRes);
+ // 刷新选中项颜色
+ TextView selectedTv = (TextView) getSelectedView();
+ if (selectedTv != null) {
+ selectedTv.setTextColor(textColor);
+ }
+ // 刷新下拉项颜色
+ if (mLogTagAdapter != null) {
+ mLogTagAdapter.notifyDataSetChanged();
+ }
+ }
+}
+
diff --git a/libappbase/src/main/res/drawable/bg_border.xml b/libappbase/src/main/res/drawable/bg_border.xml
new file mode 100644
index 0000000..58b374a
--- /dev/null
+++ b/libappbase/src/main/res/drawable/bg_border.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/bg_border_round.xml b/libappbase/src/main/res/drawable/bg_border_round.xml
new file mode 100644
index 0000000..2036d51
--- /dev/null
+++ b/libappbase/src/main/res/drawable/bg_border_round.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/bg_container_border.xml b/libappbase/src/main/res/drawable/bg_container_border.xml
new file mode 100644
index 0000000..09c5bdf
--- /dev/null
+++ b/libappbase/src/main/res/drawable/bg_container_border.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/bg_toolbar_log.xml b/libappbase/src/main/res/drawable/bg_toolbar_log.xml
new file mode 100644
index 0000000..84fda8a
--- /dev/null
+++ b/libappbase/src/main/res/drawable/bg_toolbar_log.xml
@@ -0,0 +1,41 @@
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/btn_gray_bg.xml b/libappbase/src/main/res/drawable/btn_gray_bg.xml
new file mode 100644
index 0000000..fbfb7c8
--- /dev/null
+++ b/libappbase/src/main/res/drawable/btn_gray_bg.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/ic_bug.xml b/libappbase/src/main/res/drawable/ic_bug.xml
new file mode 100644
index 0000000..41a7159
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_bug.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/drawable/ic_content_copy.xml b/libappbase/src/main/res/drawable/ic_content_copy.xml
new file mode 100644
index 0000000..0a8394f
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_content_copy.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/drawable/ic_debug_step_over.xml b/libappbase/src/main/res/drawable/ic_debug_step_over.xml
new file mode 100644
index 0000000..e7b81e2
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_debug_step_over.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/ic_key.xml b/libappbase/src/main/res/drawable/ic_key.xml
new file mode 100644
index 0000000..4cea6c7
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_key.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/drawable/ic_launcher_background.xml b/libappbase/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..9486190
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/ic_winboll.xml b/libappbase/src/main/res/drawable/ic_winboll.xml
new file mode 100644
index 0000000..21f28b1
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_winboll.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
diff --git a/libappbase/src/main/res/drawable/ic_winboll_beta.xml b/libappbase/src/main/res/drawable/ic_winboll_beta.xml
new file mode 100644
index 0000000..06fa725
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_winboll_beta.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/ic_winboll_logo.xml b/libappbase/src/main/res/drawable/ic_winboll_logo.xml
new file mode 100644
index 0000000..ea28987
--- /dev/null
+++ b/libappbase/src/main/res/drawable/ic_winboll_logo.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/drawable/shape_edittext_bg.xml b/libappbase/src/main/res/drawable/shape_edittext_bg.xml
new file mode 100644
index 0000000..d284d6a
--- /dev/null
+++ b/libappbase/src/main/res/drawable/shape_edittext_bg.xml
@@ -0,0 +1,18 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/activity_about.xml b/libappbase/src/main/res/layout-night/activity_about.xml
new file mode 100644
index 0000000..962c60f
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/activity_about.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/activity_globalcrash.xml b/libappbase/src/main/res/layout-night/activity_globalcrash.xml
new file mode 100644
index 0000000..cf4e1f7
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/activity_globalcrash.xml
@@ -0,0 +1,16 @@
+
+>
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/activity_log.xml b/libappbase/src/main/res/layout-night/activity_log.xml
new file mode 100644
index 0000000..b49e51a
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/activity_log.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/activity_nfc_rsa_operate.xml b/libappbase/src/main/res/layout-night/activity_nfc_rsa_operate.xml
new file mode 100644
index 0000000..d19cd6e
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/activity_nfc_rsa_operate.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/layout-night/dialog_sign_get.xml b/libappbase/src/main/res/layout-night/dialog_sign_get.xml
new file mode 100644
index 0000000..9d6256d
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/dialog_sign_get.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+ />
+
+
+
diff --git a/libappbase/src/main/res/layout-night/dialog_winboll_host.xml b/libappbase/src/main/res/layout-night/dialog_winboll_host.xml
new file mode 100644
index 0000000..642b30b
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/dialog_winboll_host.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/item_logtag.xml b/libappbase/src/main/res/layout-night/item_logtag.xml
new file mode 100644
index 0000000..f00bf13
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/item_logtag.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/layout_about_view.xml b/libappbase/src/main/res/layout-night/layout_about_view.xml
new file mode 100644
index 0000000..a1753ff
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/layout_about_view.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/view_globalcrashreport.xml b/libappbase/src/main/res/layout-night/view_globalcrashreport.xml
new file mode 100644
index 0000000..a5f427e
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/view_globalcrashreport.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout-night/view_log.xml b/libappbase/src/main/res/layout-night/view_log.xml
new file mode 100644
index 0000000..d8459a0
--- /dev/null
+++ b/libappbase/src/main/res/layout-night/view_log.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/layout/activity_about.xml b/libappbase/src/main/res/layout/activity_about.xml
new file mode 100644
index 0000000..962c60f
--- /dev/null
+++ b/libappbase/src/main/res/layout/activity_about.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout/activity_globalcrash.xml b/libappbase/src/main/res/layout/activity_globalcrash.xml
new file mode 100644
index 0000000..ab9120e
--- /dev/null
+++ b/libappbase/src/main/res/layout/activity_globalcrash.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout/activity_log.xml b/libappbase/src/main/res/layout/activity_log.xml
new file mode 100644
index 0000000..b49e51a
--- /dev/null
+++ b/libappbase/src/main/res/layout/activity_log.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout/activity_nfc_rsa_operate.xml b/libappbase/src/main/res/layout/activity_nfc_rsa_operate.xml
new file mode 100644
index 0000000..d19cd6e
--- /dev/null
+++ b/libappbase/src/main/res/layout/activity_nfc_rsa_operate.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/layout/dialog_sign_get.xml b/libappbase/src/main/res/layout/dialog_sign_get.xml
new file mode 100644
index 0000000..9d6256d
--- /dev/null
+++ b/libappbase/src/main/res/layout/dialog_sign_get.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+ />
+
+
+
diff --git a/libappbase/src/main/res/layout/dialog_winboll_host.xml b/libappbase/src/main/res/layout/dialog_winboll_host.xml
new file mode 100644
index 0000000..642b30b
--- /dev/null
+++ b/libappbase/src/main/res/layout/dialog_winboll_host.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout/item_logtag.xml b/libappbase/src/main/res/layout/item_logtag.xml
new file mode 100644
index 0000000..f00bf13
--- /dev/null
+++ b/libappbase/src/main/res/layout/item_logtag.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout/layout_about_view.xml b/libappbase/src/main/res/layout/layout_about_view.xml
new file mode 100644
index 0000000..a1753ff
--- /dev/null
+++ b/libappbase/src/main/res/layout/layout_about_view.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout/notification_crash.xml b/libappbase/src/main/res/layout/notification_crash.xml
new file mode 100644
index 0000000..6fbe3e3
--- /dev/null
+++ b/libappbase/src/main/res/layout/notification_crash.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/layout/notification_crash_expanded.xml b/libappbase/src/main/res/layout/notification_crash_expanded.xml
new file mode 100644
index 0000000..0a1a118
--- /dev/null
+++ b/libappbase/src/main/res/layout/notification_crash_expanded.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/layout/view_globalcrashreport.xml b/libappbase/src/main/res/layout/view_globalcrashreport.xml
new file mode 100644
index 0000000..a5f427e
--- /dev/null
+++ b/libappbase/src/main/res/layout/view_globalcrashreport.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libappbase/src/main/res/layout/view_log.xml b/libappbase/src/main/res/layout/view_log.xml
new file mode 100644
index 0000000..b728fbd
--- /dev/null
+++ b/libappbase/src/main/res/layout/view_log.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/values-night/attrs.xml b/libappbase/src/main/res/values-night/attrs.xml
new file mode 100644
index 0000000..3cb4ac4
--- /dev/null
+++ b/libappbase/src/main/res/values-night/attrs.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/values-night/colors.xml b/libappbase/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..4612c3a
--- /dev/null
+++ b/libappbase/src/main/res/values-night/colors.xml
@@ -0,0 +1,71 @@
+
+
+ #FF1E3A5F
+ #FF15253D
+ #FF4DA6FF
+ #FFE0E0E0
+ #FF0D1B2A
+
+
+ #FFFFFF
+ #000000
+
+
+ #4A90E2
+ #2196F3
+ #1976D2
+ #66BB6A
+ #4CAF50
+ #388E3C
+ #EF5350
+ #F44336
+ #D32F2F
+ #FFF59D
+ #FFC107
+ #FFA000
+ #FF9800
+ #9C27B0
+
+
+ #00000000
+ #80000000
+
+
+ #1A1A1A
+ #262626
+ #333333
+ #4D4D4D
+ #666666
+ #808080
+ #999999
+ #B3B3B3
+ #CCCCCC
+
+
+ #4D333333
+ #80333333
+ #B3333333
+
+ #333333
+ #666666
+ #999999
+ #CCCCCC
+
+
+ #804D4D4D
+ #4D1A1A1A
+
+
+ #666666
+ #4D4D4D
+ #333333
+
+
+ #FF0D1B2A
+ #FFE0E0E0
+ #FFE0E0E0
+ #FF1E3A5F
+
+ #FF00FF00
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/values-night/styles.xml b/libappbase/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..b8d576a
--- /dev/null
+++ b/libappbase/src/main/res/values-night/styles.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/values/array.xml b/libappbase/src/main/res/values/array.xml
new file mode 100644
index 0000000..fdb5cac
--- /dev/null
+++ b/libappbase/src/main/res/values/array.xml
@@ -0,0 +1,11 @@
+
+
+
+ - Off
+ - Error
+ - Warn
+ - Info
+ - Debug
+ - Verbose
+
+
diff --git a/libappbase/src/main/res/values/attrs.xml b/libappbase/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..b569d4f
--- /dev/null
+++ b/libappbase/src/main/res/values/attrs.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/values/colors.xml b/libappbase/src/main/res/values/colors.xml
new file mode 100644
index 0000000..28712e5
--- /dev/null
+++ b/libappbase/src/main/res/values/colors.xml
@@ -0,0 +1,71 @@
+
+
+ #FF00B322
+ #FF008F1A
+ #FF8DFFA2
+ #FF000000
+ #FFF5F5F5
+
+
+ #FFFFFF
+ #000000
+
+
+ #4A90E2
+ #2196F3
+ #1976D2
+ #66BB6A
+ #4CAF50
+ #388E3C
+ #EF5350
+ #F44336
+ #D32F2F
+ #FFF59D
+ #FFC107
+ #FFA000
+ #FF9800
+ #9C27B0
+
+
+ #00000000
+ #80000000
+
+
+ #F5F5F5
+ #EEEEEE
+ #E0E0E0
+ #BDBDBD
+ #9E9E9E
+ #757575
+ #616161
+ #424242
+ #212121
+
+
+ #4D9E9E9E
+ #809E9E9E
+ #B39E9E9E
+
+ #EEE
+ #999
+ #666
+ #333
+
+
+ #809E9E9E
+ #4D424242
+
+
+ #9E9E9E
+ #757575
+ #E0E0E0
+
+
+ #FFF5F5F5
+ #FF000000
+ #FF000000
+ #FF00B322
+
+ #FF808080
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/values/dimens.xml b/libappbase/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..46eef9b
--- /dev/null
+++ b/libappbase/src/main/res/values/dimens.xml
@@ -0,0 +1,19 @@
+
+
+ 12dp
+ 2dp
+
+ 65dp
+ 34dp
+
+ 100dp
+ 20dp
+
+ 60dp
+ 16dp
+ @dimen/log_spinner_width
+ @dimen/log_spinner_height
+ @dimen/log_text_size
+ @dimen/log_text_padding
+
+
diff --git a/libappbase/src/main/res/values/strings.xml b/libappbase/src/main/res/values/strings.xml
new file mode 100644
index 0000000..c2219d5
--- /dev/null
+++ b/libappbase/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+
+
+ libappbase
+ Hello, world!
+ cc.winboll.studio
+ studio@winboll.cc
+
diff --git a/libappbase/src/main/res/values/styles.xml b/libappbase/src/main/res/values/styles.xml
new file mode 100644
index 0000000..92d8143
--- /dev/null
+++ b/libappbase/src/main/res/values/styles.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/libappbase/src/main/res/xml/network_security_config.xml b/libappbase/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..54f4ef4
--- /dev/null
+++ b/libappbase/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,35 @@
+
+
+
+
+ winboll.cc
+ localhost
+ 127.0.0.1
+
+
+ 10.8.0.0
+ 10.8.0.1
+ 10.8.0.2
+ 10.8.0.3
+ 10.8.0.4
+ 10.8.0.5
+ 10.8.0.6
+ 10.8.0.7
+ 10.8.0.8
+ 10.8.0.9
+ 10.8.0.10
+ 10.8.0.11
+ 10.8.0.12
+ 10.8.0.13
+ 10.8.0.14
+ 10.8.0.15
+ 10.8.0.16
+ 10.8.0.17
+ 10.8.0.18
+ 10.8.0.19
+
+
+
+
+
+