android 悬浮窗 模拟微信通话返回桌面悬浮

现有一款IM聊天需求,在通话页面点击缩小视图或者Home键返回桌面,点击悬浮窗回到通话页面这样一个需求。

权限

首先是权限的获取,请注意,在Android 8.0及以上版本中,需要申请悬浮窗权限(SYSTEM_ALERT_WINDOW)才能显示悬浮窗。你可以在应用启动时请求该权限,或者引导用户手动开启该权限。

后面两个权限是按返回键返回上个页面不杀死该通话页面用的任务栈用的

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.GET_TASKS" />
<uses-permission android:name="android.permission.REORDER_TASKS"/>


   <activity android:name="com.example.myapplication.ui.CallActivity"  android:launchMode="singleInstance"  android:exported="true" >

下面是悬浮窗的的CallFloatWindow类,这个类是悬浮窗的实现方法,因为我的项目有视频和语音通话功能,所有这个类的东西比较多,如果不需要这些杂七杂八的东西,看下show()方法,把计时和显示视频View相关的东西去了,就可以直接通过**CallFloatWindow.getInstance().show()**方法使用,这是最基本的使用方式,看你的场景需求

package com.example.myapplication.widget;

import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.PixelFormat;
import android.graphics.Point;
import android.os.Build;
import android.os.SystemClock;
import android.text.format.DateUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.SurfaceView;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.RelativeLayout;
import android.widget.TextView;

import com.example.myapplication.MyAppLication;
import com.example.myapplication.R;
import com.example.myapplication.ui.CallActivity;

import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;


public class CallFloatWindow {
    private static final String TAG = "EaseCallFloatWindow";

    private static CallFloatWindow instance;

    private WindowManager windowManager = null;
    private WindowManager.LayoutParams layoutParams = null;
    private EaseCallMemberView memberView;
    private SurfaceView surfaceView;

    private View floatView;
    private TextView tvContent;
    private int screenWidth;
    private int floatViewWidth;
    private int callType;  //  0语音  1视频
    private int uId;
    private long costSeconds;
    private ConferenceInfo conferenceInfo;
    private SingleCallInfo singleCallInfo;
    // 计时器
    private long mNow; // the currently displayed time
    private long mBase;
    private boolean callState = true; //是否连接成功通话
    private StringBuilder mRecycle = new StringBuilder("MM:SS");
    Timer timer ;

    public CallFloatWindow(Context context) {
        initFloatWindow(context);
    }

    private CallFloatWindow() {
    }


    public static CallFloatWindow getInstance(Context context) {
        if (instance == null) {
            instance = new CallFloatWindow(context);
        }
        return instance;
    }

    public static CallFloatWindow getInstance() {
        if (instance == null) {
            synchronized (CallFloatWindow.class) {
                if (instance == null) {
                    instance = new CallFloatWindow();
                }
            }
        }
        return instance;
    }

    private void initFloatWindow(Context context) {
        windowManager = (WindowManager) context.getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
        Point point = new Point();
        windowManager.getDefaultDisplay().getSize(point);
        screenWidth = point.x;
    }

    private MyChronometer chronometer;

    public void setCostSeconds(long seconds) {
        this.costSeconds = seconds;
    }

    /**
     * add float window
     */
    public void show() { // 0: voice call; 1: video call;
        if (floatView != null) {
            return;
        }
        layoutParams = new WindowManager.LayoutParams();
        layoutParams.gravity = Gravity.END | Gravity.TOP;
        layoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
        layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
        layoutParams.format = PixelFormat.TRANSPARENT;
        layoutParams.type = getSupportedWindowType();
        layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
        //显示的位置
        layoutParams.y = 300;
        floatView = LayoutInflater.from(MyAppLication.context).inflate(R.layout.activity_float_window, null);
        tvContent = (TextView) floatView.findViewById(R.id.tv_content);
        floatView.setFocusableInTouchMode(true);

        if (floatView instanceof ViewGroup) {
            chronometer = new MyChronometer(MyAppLication.context);
            ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(150, 150);
            ((ViewGroup) floatView).addView(chronometer, params);
        }

        windowManager.addView(floatView, layoutParams);
        startCount();
        if (callType == 1) {
            conferenceInfo = new ConferenceInfo();
        } else {
            singleCallInfo = new SingleCallInfo();
        }
        floatView.post(new Runnable() {
            @Override
            public void run() {
                // Get the size of floatView;
                if (floatView != null) {
                    floatViewWidth = floatView.getWidth();
                }
            }
        });

        floatView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Class<? extends Activity> callClass = CallActivity.class;
                Log.e("TAG", "current call class: "+callClass);
                if(callClass != null) {
                    Intent intent = new Intent(MyAppLication.context, callClass);
                    intent.addFlags(FLAG_ACTIVITY_NEW_TASK);
//                    if(callType != EaseCallType.CONFERENCE_CALL) {
//                        intent.putExtra("uId", singleCallInfo != null ? singleCallInfo.remoteUid : 0);
//                    }
//                    intent.putExtra("isClickByFloat", true);
//                    EaseCallKit.getInstance().getAppContext().startActivity(intent);
                    MyAppLication.context.startActivity(intent);
                }else {
                   Log.e(TAG, "Current call class is null, please not call EaseCallKit.getInstance().releaseCall() before the call is finished");
                }
                //dismiss();
            }
        });

        floatView.setOnTouchListener(new View.OnTouchListener() {
            boolean result = false;

            int left;
            int top;
            float startX = 0;
            float startY = 0;

            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        result = false;
                        startX = event.getRawX();
                        startY = event.getRawY();

                        left = layoutParams.x;
                        top = layoutParams.y;

                        break;
                    case MotionEvent.ACTION_MOVE:
                        if (Math.abs(event.getRawX() - startX) > 20 || Math.abs(event.getRawY() - startY) > 20) {
                            result = true;
                        }

                        int deltaX = (int) (startX - event.getRawX());

                        layoutParams.x = left + deltaX;
                        layoutParams.y = (int) (top + event.getRawY() - startY);
                        windowManager.updateViewLayout(floatView, layoutParams);
                        break;
                    case MotionEvent.ACTION_UP:
                        smoothScrollToBorder();
                        break;
                }
                return result;
            }
        });
        initTimeObserver();
    }

    public int getSupportedWindowType() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            return WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
        } else {
            return WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
        }
    }

    private void startCount() {
        if (chronometer != null) {
            chronometer.setBase(SystemClock.elapsedRealtime());
            chronometer.start();
        }
    }

    private void stopCount() {
        if (chronometer != null) {
            chronometer.stop();
        }
    }

    /**
     * Should call the method before call {@link #dismiss()}
     *
     * @return Cost seconds in float window
     */
    public long getFloatCostSeconds() {
        if (chronometer != null) {
            return chronometer.getCostSeconds();
        }
        Log.e(TAG, "chronometer is null, can not get cost seconds");
        return 0;
    }

    /**
     * Should call the method before call {@link #dismiss()}
     *
     * @return Total cost seconds
     */
    public long getTotalCostSeconds() {
        if (chronometer != null) {
            Log.e("activity", "costSeconds: " + chronometer.getCostSeconds());
        }
        if (chronometer != null) {
            return costSeconds + chronometer.getCostSeconds();
        }
        Log.e(TAG, "chronometer is null, can not get total cost seconds");
        return 0;
    }

    public void setConferenceInfo(ConferenceInfo info) {
        this.conferenceInfo = info;
    }

    public ConferenceInfo getConferenceInfo() {
        return conferenceInfo;
    }

    /**
     * Update conference call state
     *
     * @param view
     */
    public void update(EaseCallMemberView view) {
        if (floatView == null) {
            return;
        }
        memberView = view;
        // uId = memberView.getUserId();
        if (memberView.isVideoOff()) { // 视频未开启
            floatView.findViewById(R.id.layout_call_voice).setVisibility(View.VISIBLE);
            floatView.findViewById(R.id.layout_call_video).setVisibility(View.GONE);
        } else { // 视频已开启
            floatView.findViewById(R.id.layout_call_voice).setVisibility(View.GONE);
            floatView.findViewById(R.id.layout_call_video).setVisibility(View.VISIBLE);

//            int uId = memberView.getUserId();
//            boolean isSelf = TextUtils.equals(userAccount, EMClient.getInstance().getCurrentUser());
            prepareSurfaceView(false, uId);
        }
    }

    /**
     * Update the sing call state
     *
     * @param isSelf
     * @param curUid
     * @param remoteUid
     * @param surface
     */
    public void update(boolean isSelf, int curUid, int remoteUid, boolean surface) {
        if (singleCallInfo == null) {
            singleCallInfo = new SingleCallInfo();
        }
        singleCallInfo.curUid = curUid;
        singleCallInfo.remoteUid = remoteUid;
        if (callType == 1 && surface) {
            floatView.findViewById(R.id.layout_call_voice).setVisibility(View.GONE);
            floatView.findViewById(R.id.layout_call_video).setVisibility(View.VISIBLE);
            prepareSurfaceView(isSelf, isSelf ? curUid : remoteUid);
        } else {
            floatView.findViewById(R.id.layout_call_voice).setVisibility(View.VISIBLE);
            floatView.findViewById(R.id.layout_call_video).setVisibility(View.GONE);
        }
    }

    public void setContent(String text) {
        if (tvContent != null) {
            tvContent.post(new Runnable() {
                @Override
                public void run() {
                    tvContent.setText(text);
                }
            });
        }
    }

    public SingleCallInfo getSingleCallInfo() {
        return singleCallInfo;
    }

    public void setCameraDirection(boolean isFront, boolean changeFlag) {
        if (singleCallInfo == null) {
            singleCallInfo = new SingleCallInfo();
        }
        singleCallInfo.isCameraFront = isFront;
        singleCallInfo.changeFlag = changeFlag;
    }

    public boolean isShowing() {
        if (callType == 1) {
            return memberView != null;
        } else {
            return floatView != null;
        }
    }

    /**
     * For the single call, only the remote uid is returned
     *
     * @return
     */
    public int getUid() {
      /*  if(callType == EaseCallType.CONFERENCE_CALL && memberView != null) {
            return memberView.getUserId();
        }else if((callType == EaseCallType.SINGLE_VIDEO_CALL || callType == EaseCallType.SINGLE_VOICE_CALL) && singleCallInfo != null) {
            return singleCallInfo.remoteUid;
        }*/
        return -1;
    }

    /**
     * 停止悬浮窗
     */
    public void dismiss() {
        Log.i(TAG, "dismiss: ");
        if (windowManager != null && floatView != null) {
            stopCount();
            windowManager.removeView(floatView);
        }
        cancel();
        floatView = null;
        memberView = null;
        surfaceView = null;
        if (conferenceInfo != null) {
            conferenceInfo = null;
        }
        if (singleCallInfo != null) {
            singleCallInfo = null;
        }
    }

    /**
     * 设置视频
     */
    private void prepareSurfaceView(boolean isSelf, int uid) {
        RelativeLayout surfaceLayout = (RelativeLayout) floatView.findViewById(R.id.layout_call_video);
        surfaceLayout.removeAllViews();
        // surfaceView =pocEngine.getRendererView(IPocEngineEventHandler.SurfaceType.LOCAL_SURFACE_TEXTURE);
        surfaceLayout.addView(surfaceView);
        surfaceView.setZOrderOnTop(false);
        surfaceView.setZOrderMediaOverlay(false);
       /* if(isSelf){
            rtcEngine.setupLocalVideo(new VideoCanvas(surfaceView, VideoCanvas.RENDER_MODE_HIDDEN,0));
        }else{
            rtcEngine.setupRemoteVideo(new VideoCanvas(surfaceView, VideoCanvas.RENDER_MODE_HIDDEN, uid));
        }*/
    }

    private void smoothScrollToBorder() {
        Log.i(TAG, "screenWidth: " + screenWidth + ", floatViewWidth: " + floatViewWidth);
        int splitLine = screenWidth / 2 - floatViewWidth / 2;
        final int left = layoutParams.x;
        final int top = layoutParams.y;
        int targetX;

        if (left < splitLine) {
            // 滑动到最左边
            targetX = 0;
        } else {
            // 滑动到最右边
            targetX = screenWidth - floatViewWidth;
        }

        ValueAnimator animator = ValueAnimator.ofInt(left, targetX);
        animator.setDuration(100)
                .addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        if (floatView == null) return;

                        int value = (int) animation.getAnimatedValue();
                        Log.i(TAG, "onAnimationUpdate, value: " + value);
                        layoutParams.x = value;
                        layoutParams.y = top;
                        windowManager.updateViewLayout(floatView, layoutParams);
                    }
                });
        animator.start();
    }

    public static class SingleCallInfo {
        /**
         * Current user's uid
         */
        public int curUid;
        /**
         * The other size of uid
         */
        public int remoteUid;
        /**
         * Camera direction: front or back
         */
        public boolean isCameraFront = true;
        /**
         * A tag used to mark the switch between local and remote video
         */
        public boolean changeFlag;
    }

    /**
     * Use to hold the conference info
     */
    public static class ConferenceInfo {
        public Map<Integer, ViewState> uidToViewList;
        public Map<String, Integer> userAccountToUidMap;
        //  public Map<Integer, EaseUserAccount> uidToUserAccountMap;

        /**
         * Hold the states of {@link EaseCallMemberView}
         */
        public static class ViewState {
            // video state
            public boolean isVideoOff;
            // audio state
            public boolean isAudioOff;
            // screen mode
            public boolean isFullScreenMode;
            // speak activate state
            public boolean speakActivated;
            // camera direction
            public boolean isCameraFront;
        }
    }

    public long getmBase() {
        return mBase;
    }

    public void setmBase(long mBase) {
        this.mBase = mBase;
    }

    public boolean isCallState() {
        return callState;
    }

    public void setCallState(boolean callState) {
        this.callState = callState;
    }

    public void initTimeObserver() {
        timer = new Timer();
        //双重保证通话中
        if (callState) {
            timer.schedule(new TimerTask() {
                @Override
                public void run() {
                    updateText(SystemClock.elapsedRealtime());
                }
            }, 0, 1000);
           /* Observable.timer(1000, TimeUnit.MILLISECONDS).subscribeOn(Schedulers.newThread())
                    .doOnSubscribe(disposable -> disposableObserver=disposable)
                    .subscribeOn(Schedulers.io())
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe(new Consumer<Long>() {
                        @Override
                        public void accept(Long aLong) throws Exception {
                            updateText(SystemClock.elapsedRealtime());
                        }
                    });*/
        } else {
            setContent("等待接听");
        }

    }

    public void cancel() {
        //timer cancel后不能再次调用schedule方法,需要重新创建,所以可以调用task.cancel方法取消任务
        //timer.cancel();
        if (timer != null) {
            timer.cancel();
        }
    }

    private synchronized void updateText(long now) {
        mNow = now;
        long seconds = now - mBase;
        seconds /= 1000;
        boolean negative = false;
        if (seconds < 0) {
            seconds = -seconds;
            negative = true;
        }
        String text = DateUtils.formatElapsedTime(mRecycle, seconds);
        setContent(text);
    }
}

activity_float_window布局文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="88dp"
    android:layout_height="98dp"
    android:background="#D68888"
    android:padding="20dp"
    android:orientation="vertical">

    <LinearLayout
        android:id="@+id/layout_call_voice"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:gravity="center"
        android:orientation="vertical"
        android:padding="20dip"
        android:background="#d8d8d8"
        >
        <ImageView
            android:id="@+id/iv_typer"
            android:layout_width="22dp"
            android:layout_height="22dp"
            android:src="@drawable/ic_launcher_foreground" />
        <TextView
            android:id="@+id/tv_content"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@color/black"
            android:textSize="12sp"
            android:text=""
            android:textStyle="bold"
            android:layout_marginTop="6dp"
            />
    </LinearLayout>
<!--显示视频窗口-->
    <RelativeLayout
        android:id="@+id/layout_call_video"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@android:color/transparent"
        android:gravity="center"
        android:orientation="vertical"
        android:padding="20dip"
        android:visibility="gone" />
</RelativeLayout>

上面说了有计时器,这个计时器我是加了个自定义的MyChronometer类来管理计时器

复制代码
<string name="negative_duration">\u2212<xliff:g id="time" example="1:14">%1$s</xliff:g></string>

attrs.xml里加个自定义的属性

复制代码
<declare-styleable name="MyChronometer">
    <!-- Format string: if specified, the Chronometer will display this
         string, with the first "%s" replaced by the current timer value
         in "MM:SS" or "H:MM:SS" form.
         If no format string is specified, the Chronometer will simply display
         "MM:SS" or "H:MM:SS". -->
    <attr name="format" format="string" localization="suggested" />
    <!-- Specifies whether this Chronometer counts down or counts up from the base.
          If not specified this is false and the Chronometer counts up. -->
    <attr name="countDown" format="boolean" />
</declare-styleable>
package com.example.myapplication.widget;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.TypedArray;
import android.icu.text.MeasureFormat;
import android.icu.util.Measure;
import android.icu.util.MeasureUnit;
import android.net.Uri;
import android.os.Build;
import android.os.SystemClock;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.Chronometer;

import androidx.annotation.InspectableProperty;


import com.example.myapplication.R;

import java.util.ArrayList;
import java.util.Formatter;
import java.util.IllegalFormatException;
import java.util.Locale;

public class MyChronometer extends androidx.appcompat.widget.AppCompatTextView {

    private static final String TAG = "Chronometer";

    /**
     * A callback that notifies when the chronometer has incremented on its own.
     */
    public interface OnChronometerTickListener {

        /**
         * Notification that the chronometer has changed.
         */
        void onChronometerTick(MyChronometer chronometer);

    }

    private long mBase;
    private long mNow; // the currently displayed time
    private boolean mVisible;
    private boolean mStarted;
    private boolean mRunning;
    private boolean mLogged;
    private String mFormat;
    private Formatter mFormatter;
    private Locale mFormatterLocale;
    private Object[] mFormatterArgs = new Object[1];
    private StringBuilder mFormatBuilder;
    private OnChronometerTickListener mOnChronometerTickListener;
    private StringBuilder mRecycle = new StringBuilder(8);
    private boolean mCountDown;
    private long costSeconds;

    /**
     * Initialize this Chronometer object.
     * Sets the base to the current time.
     */
    public MyChronometer(Context context) {
        this(context, null, 0);
    }

    /**
     * Initialize with standard view layout information.
     * Sets the base to the current time.
     */
    public MyChronometer(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    /**
     * Initialize with standard view layout information and style.
     * Sets the base to the current time.
     */
    public MyChronometer(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        final TypedArray a = context.obtainStyledAttributes(
                attrs, R.styleable.MyChronometer, defStyleAttr, 0);
//        saveAttributeDataForStyleable(context, R.styleable.MyChronometer,
//                attrs, a, defStyleAttr, 0);
        setFormat(a.getString(R.styleable.MyChronometer_format));
        setCountDown(a.getBoolean(R.styleable.MyChronometer_countDown, false));
        a.recycle();

        init();
    }

    private void init() {
        mBase = SystemClock.elapsedRealtime();
        updateText(mBase);
    }

    /**
     * Set this view to count down to the base instead of counting up from it.
     *
     * @param countDown whether this view should count down
     *
     * @see #setBase(long)
     */
    public void setCountDown(boolean countDown) {
        mCountDown = countDown;
        updateText(SystemClock.elapsedRealtime());
    }

    /**
     * @return whether this view counts down
     *
     * @see #setCountDown(boolean)
     */
    @InspectableProperty
    public boolean isCountDown() {
        return mCountDown;
    }

    /**
     * @return whether this is the final countdown
     */
    public boolean isTheFinalCountDown() {
        try {
            getContext().startActivity(
                    new Intent(Intent.ACTION_VIEW, Uri.parse("https://youtu.be/9jK-NcRmVcw"))
                            .addCategory(Intent.CATEGORY_BROWSABLE)
                            .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT
                                    | Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT));
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * Set the time that the count-up timer is in reference to.
     *
     * @param base Use the {@link SystemClock#elapsedRealtime} time base.
     */
    public void setBase(long base) {
        mBase = base;
        dispatchChronometerTick();
        updateText(SystemClock.elapsedRealtime());
    }

    /**
     * Return the base time as set through {@link #setBase}.
     */
    public long getBase() {
        return mBase;
    }

    /**
     * Sets the format string used for display.  The Chronometer will display
     * this string, with the first "%s" replaced by the current timer value in
     * "MM:SS" or "H:MM:SS" form.
     *
     * If the format string is null, or if you never call setFormat(), the
     * Chronometer will simply display the timer value in "MM:SS" or "H:MM:SS"
     * form.
     *
     * @param format the format string.
     */
    public void setFormat(String format) {
        mFormat = format;
        if (format != null && mFormatBuilder == null) {
            mFormatBuilder = new StringBuilder(format.length() * 2);
        }
    }

    /**
     * Returns the current format string as set through {@link #setFormat}.
     */
    @InspectableProperty
    public String getFormat() {
        return mFormat;
    }

    /**
     * Sets the listener to be called when the chronometer changes.
     *
     * @param listener The listener.
     */
    public void setOnChronometerTickListener(OnChronometerTickListener listener) {
        mOnChronometerTickListener = listener;
    }

    /**
     * @return The listener (may be null) that is listening for chronometer change
     *         events.
     */
    public OnChronometerTickListener getOnChronometerTickListener() {
        return mOnChronometerTickListener;
    }

    /**
     * Start counting up.  This does not affect the base as set from {@link #setBase}, just
     * the view display.
     *
     * Chronometer works by regularly scheduling messages to the handler, even when the
     * Widget is not visible.  To make sure resource leaks do not occur, the user should
     * make sure that each start() call has a reciprocal call to {@link #stop}.
     */
    public void start() {
        mStarted = true;
        updateRunning();
    }

    /**
     * Stop counting up.  This does not affect the base as set from {@link #setBase}, just
     * the view display.
     *
     * This stops the messages to the handler, effectively releasing resources that would
     * be held as the chronometer is running, via {@link #start}.
     */
    public void stop() {
        mStarted = false;
        updateRunning();
    }

    /**
     * The same as calling {@link #start} or {@link #stop}.
     * @hide pending API council approval
     */
    public void setStarted(boolean started) {
        mStarted = started;
        updateRunning();
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mVisible = false;
        updateRunning();
    }

    @Override
    protected void onWindowVisibilityChanged(int visibility) {
        //continue when view is hidden
        visibility = View.VISIBLE;
        super.onWindowVisibilityChanged(visibility);
        mVisible = visibility == VISIBLE;
        updateRunning();
    }

    @Override
    protected void onVisibilityChanged(View changedView, int visibility) {
        super.onVisibilityChanged(changedView, visibility);
        updateRunning();
    }

    private synchronized void updateText(long now) {
        mNow = now;
        Log.e(TAG, "now: "+mNow + " mBase: "+mBase + " cost: "+(mNow - mBase));
        long seconds = mCountDown ? mBase - now : now - mBase;
        seconds /= 1000;
        boolean negative = false;
        if (seconds < 0) {
            seconds = -seconds;
            negative = true;
        }
        costSeconds = seconds;
        String text = DateUtils.formatElapsedTime(mRecycle, seconds);
        if (negative) {
            text = getResources().getString(R.string.negative_duration, text);
        }

        if (mFormat != null) {
            Locale loc = Locale.getDefault();
            if (mFormatter == null || !loc.equals(mFormatterLocale)) {
                mFormatterLocale = loc;
                mFormatter = new Formatter(mFormatBuilder, loc);
            }
            mFormatBuilder.setLength(0);
            mFormatterArgs[0] = text;
            try {
                mFormatter.format(mFormat, mFormatterArgs);
                text = mFormatBuilder.toString();
            } catch (IllegalFormatException ex) {
                if (!mLogged) {
                    Log.w(TAG, "Illegal format string: " + mFormat);
                    mLogged = true;
                }
            }
        }
        setText(text);
    }

    private void updateRunning() {
        boolean running = mVisible && mStarted && isShown();
        if (running != mRunning) {
            if (running) {
                updateText(SystemClock.elapsedRealtime());
                dispatchChronometerTick();
                postDelayed(mTickRunnable, 1000);
            } else {
                removeCallbacks(mTickRunnable);
            }
            mRunning = running;
        }
    }

    private final Runnable mTickRunnable = new Runnable() {
        @Override
        public void run() {
            if (mRunning) {
                updateText(SystemClock.elapsedRealtime());
                dispatchChronometerTick();
                postDelayed(mTickRunnable, 1000);
            }
        }
    };

    void dispatchChronometerTick() {
        if (mOnChronometerTickListener != null) {
            mOnChronometerTickListener.onChronometerTick(this);
        }
    }

    private static final int MIN_IN_SEC = 60;
    private static final int HOUR_IN_SEC = MIN_IN_SEC*60;
    private static String formatDuration(long ms) {
        int duration = (int) (ms / DateUtils.SECOND_IN_MILLIS);
        if (duration < 0) {
            duration = -duration;
        }

        int h = 0;
        int m = 0;

        if (duration >= HOUR_IN_SEC) {
            h = duration / HOUR_IN_SEC;
            duration -= h * HOUR_IN_SEC;
        }
        if (duration >= MIN_IN_SEC) {
            m = duration / MIN_IN_SEC;
            duration -= m * MIN_IN_SEC;
        }
        final int s = duration;

        final ArrayList<Measure> measures = new ArrayList<Measure>();
        if (h > 0) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                measures.add(new Measure(h, MeasureUnit.HOUR));
            }
        }
        if (m > 0) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                measures.add(new Measure(m, MeasureUnit.MINUTE));
            }
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            measures.add(new Measure(s, MeasureUnit.SECOND));
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            return MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.WIDE)
                    .formatMeasures(measures.toArray(new Measure[measures.size()]));
        }
        return "";
    }

    @SuppressLint("GetContentDescriptionOverride")
    @Override
    public CharSequence getContentDescription() {
        return formatDuration(mNow - mBase);
    }

    @Override
    public CharSequence getAccessibilityClassName() {
        return Chronometer.class.getName();
    }

    public long getCostSeconds() {
        return costSeconds;
    }
}

这里会用到BaseActivityLifecycleCallbacks这个类是管理callactivity的返回键的 ,返回上个页面,通话页面不会finish掉

public class MyAppLication extends Application {
    public BaseActivityLifecycleCallbacks mLifecycleCallbacks;
    public static MyAppLication application;

public static Context context;
    private String TAG = "MyAppLication";

    @Override
    public void onCreate() {
        super.onCreate();
        application=this;
        context= this;
      
        mLifecycleCallbacks = new BaseActivityLifecycleCallbacks();
        registerActivityLifecycleCallbacks();
    }
    private void registerActivityLifecycleCallbacks() {
        this.registerActivityLifecycleCallbacks(mLifecycleCallbacks);
    }
    public BaseActivityLifecycleCallbacks getLifecycleCallbacks() {
        return mLifecycleCallbacks;
    }

}


public class BaseActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks, ActivityState {
    public static final int STATUS_FORCE_KILLED = -1; //应用放在后台被强杀了
    public static final int STATUS_NORMAL = 1;  //APP正常态
    //默认被初始化状态,被系统回收(强杀)状态
    public int mAppStatus = STATUS_FORCE_KILLED;

    public List<Activity> activityList = new ArrayList<>();
    public List<Activity> resumeActivity = new ArrayList<>();


    @Override
    public void onActivityCreated(Activity activity, Bundle bundle) {
        Log.e("ActivityLifecycle", "onActivityCreated " + activity.getLocalClassName());
        activityList.add(0, activity);
    }

    @Override
    public void onActivityStarted(Activity activity) {
        Log.e("ActivityLifecycle", "onActivityStarted " + activity.getLocalClassName());
    }

    @Override
    public void onActivityResumed(Activity activity) {
        Log.e("ActivityLifecycle", "onActivityResumed " + activity.getLocalClassName());
        if (!resumeActivity.contains(activity)) {
            resumeActivity.add(activity);
            if (resumeActivity.size() == 1) {
                //do nothing
            }
            restartSingleInstanceActivity(activity);
        }
    }

    @Override
    public void onActivityPaused(Activity activity) {
        Log.e("ActivityLifecycle", "onActivityPaused " + activity.getLocalClassName());
    }

    @Override
    public void onActivityStopped(Activity activity) {
        Log.e("ActivityLifecycle", "onActivityStopped " + activity.getLocalClassName());
        resumeActivity.remove(activity);
        if (resumeActivity.isEmpty()) {
            Log.e("ActivityLifecycle", "在后台了");
        }
    }

    @Override
    public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
        Log.e("ActivityLifecycle", "onActivitySaveInstanceState " + activity.getLocalClassName());
    }

    @Override
    public void onActivityDestroyed(Activity activity) {
        Log.e("ActivityLifecycle", "onActivityDestroyed " + activity.getLocalClassName());
        activityList.remove(activity);
    }

    @Override
    public Activity current() {
        return activityList.size() > 0 ? activityList.get(0) : null;
    }

    @Override
    public List<Activity> getActivityList() {
        return activityList;
    }

    @Override
    public int count() {
        return activityList.size();
    }

    @Override
    public boolean isFront() {
        return resumeActivity.size() > 0;
    }

    /**
     * 跳转到目标activity
     *
     * @param cls
     */
    public void skipToTarget(Class<?> cls) {
        if (activityList != null && activityList.size() > 0) {
            current().startActivity(new Intent(current(), cls));
            for (Activity activity : activityList) {
                activity.finish();
            }
        }

    }

    /**
     * finish target activity
     *
     * @param cls
     */
    public void finishTarget(Class<?> cls) {
        if (activityList != null && !activityList.isEmpty()) {
            for (Activity activity : activityList) {
                if (activity.getClass() == cls) {
                    activity.finish();
                }
            }
        }
    }

    /**
     * 判断app是否在前台
     *
     * @return
     */
    public boolean isOnForeground() {
        return resumeActivity != null && !resumeActivity.isEmpty();
    }


    /**
     * 用于按下home键,点击图标,检查启动模式是singleInstance,且在activity列表中首位的Activity
     * 下面的方法,专用于解决启动模式是singleInstance, 为开启悬浮框的情况
     *
     * @param activity
     */
    private void restartSingleInstanceActivity(Activity activity) {
        boolean isClickByFloat = activity.getIntent().getBooleanExtra("isClickByFloat", false);
        if(isClickByFloat) {
            return;
        }
        //刚启动,或者从桌面返回app
        if(resumeActivity.size() == 1 ) {
            return;
        }
        //至少需要activityList中至少两个activity
        if(resumeActivity.size() >= 1 && activityList.size() > 1) {
            Activity a = getOtherTaskSingleInstanceActivity(resumeActivity.get(0).getTaskId());
            if(a != null && !a.isFinishing() //没有正在finish
                    && a != activity //当前activity和列表中首个activity不相同
                    && a.getTaskId() != activity.getTaskId()
            ){
                Log.e("ActivityLifecycle", "启动了activity = "+a.getClass().getName());
                activity.startActivity(new Intent(activity, a.getClass()));
            }
        }
    }
    private Activity getOtherTaskSingleInstanceActivity(int taskId) {
        if(taskId != 0 && activityList.size() > 1) {
            for (Activity activity : activityList) {
                if(activity.getTaskId() != taskId) {
                    if(isTargetSingleInstance(activity)) {
                        return activity;
                    }
                }
            }
        }
        return null;
    }
    private boolean isTargetSingleInstance(Activity activity) {
        if(activity == null) {
            return false;
        }
        CharSequence title = activity.getTitle();
       /* if(TextUtils.equals(title, activity.getString(R.string.demo_activity_label_video_call))
                || TextUtils.equals(title, activity.getString(R.string.demo_activity_label_multi_call))) {
            return true;
        }*/
        return false;
    }

    /**
     * 此方法用于设置启动模式为singleInstance的activity调用
     * 用于解决点击悬浮框后,然后finish当前的activity,app回到桌面的问题
     * 需要如下两个权限:
     * <uses-permission android:name="android.permission.GET_TASKS" />
     * <uses-permission android:name="android.permission.REORDER_TASKS"/>
     *
     * @param activity
     */

    public void makeMainTaskToFront(Activity activity) {
        //当前activity正在finish,且可见的activity列表中只有这个正在finish的activity,且没有销毁的activity个数大于等于2
        if (activity.isFinishing() && resumeActivity.size() == 1 && resumeActivity.get(0) == activity && activityList.size() > 1) {
            ActivityManager manager = (ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
            List<ActivityManager.RunningTaskInfo> runningTasks = manager.getRunningTasks(20);
            for (int i = 0; i < runningTasks.size(); i++) {
                ActivityManager.RunningTaskInfo taskInfo = runningTasks.get(i);
                ComponentName topActivity = taskInfo.topActivity;
                //判断是否是相同的包名
                if (topActivity != null && topActivity.getPackageName().equals(activity.getPackageName())) {
                    int taskId;
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                        taskId = taskInfo.taskId;
                    } else {
                        taskId = taskInfo.id;
                    }
                    //将任务栈置于前台
                    Log.e("ActivityLifecycle", "执行moveTaskToFront,current activity:" + activity.getClass().getName());
                    manager.moveTaskToFront(taskId, ActivityManager.MOVE_TASK_WITH_HOME);
                }
            }
        }
    }
}

public interface ActivityState {
    /**
     * 得到当前Activity
     *
     * @return
     */
    Activity current();

    /**
     * 得到Activity集合
     *
     * @return
     */
    List<Activity> getActivityList();

    /**
     * 任务栈中Activity的总数
     *
     * @return
     */
    int count();

    /**
     * 判断应用是否处于前台,即是否可见
     *
     * @return
     */
    boolean isFront();
}

最后就是我们的CallActivity

class CallActivity : AppCompatActivity() {
    private lateinit var chronometer: MyChronometer  // 通话计时
    lateinit var binding: LayoutFlowwindowBinding

    //来电或外呼
    var isIncomingCall = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutFlowwindowBinding.inflate(layoutInflater)
        setContentView(binding.root)
        chronometer = findViewById(R.id.chronometer) as MyChronometer
        //     EaseCallFloatWindow(this)
        CallFloatWindow.getInstance(this)
        startCount()
        binding.open.setOnClickListener {
            showFloatWindow()
        }
        binding.close.setOnClickListener {
            stopCount()
            CallFloatWindow.getInstance(this).dismiss()
        }
    }

    override fun onStart() {
        super.onStart()
        checkFloatIntent(intent)

    }
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        checkFloatIntent(intent)
    }


    override fun onStop() {
        super.onStop()
        showFloatWindow()
    }
    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        // 是否触发按键为back键
        return if (keyCode == KeyEvent.KEYCODE_BACK) {
            onBackPressed()
            true
        } else {
            // 如果不是back键正常响应
            super.onKeyDown(keyCode, event)
        }
    }

    override fun onBackPressed() {
        // 也可以处理成悬浮窗
        AlertDialogUtils.show(this, "提示", "返回还是结束?", "返回",
            { dialog, which ->
                showFloatWindow()
                MyAppLication.application.getLifecycleCallbacks().makeMainTaskToFront(this)
            },"结束"
        ) { dialog, which -> finish()}
    }
    fun isFloatWindowShowing(): Boolean {
        return CallFloatWindow.getInstance().isShowing()
    }

    private fun startCount() {
        if (chronometer != null) {
            chronometer!!.base = SystemClock.elapsedRealtime()
            chronometer!!.start()
        }
    }

    private fun stopCount() {
        if (chronometer != null) {
            chronometer!!.stop()
        }
    }

    private fun checkFloatIntent(intent: Intent) {
        // 防止activity在后台被start至前台导致window还存在
        if (isFloatWindowShowing()) {
            val totalCostSeconds = CallFloatWindow.getInstance().totalCostSeconds
            chronometer.base = SystemClock.elapsedRealtime() - totalCostSeconds * 1000
            chronometer.start()
        }
        CallFloatWindow.getInstance().dismiss()
    }

    protected var requestOverlayPermission = false
    protected val REQUEST_CODE_OVERLAY_PERMISSION = 1002

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_CODE_OVERLAY_PERMISSION && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            // Result of window permission request, resultCode = RESULT_CANCELED
            if (Settings.canDrawOverlays(this)) {
                doShowFloatWindow()
            } else {
                Toast.makeText(
                    this,
                    "悬浮窗权限 未授权",
                    Toast.LENGTH_SHORT
                ).show()
            }
            return
        }
    }

    fun showFloatWindow() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (Settings.canDrawOverlays(this)) {
                doShowFloatWindow()
            } else { // To reqire the window permission.
                if (!requestOverlayPermission) {
                    try {
                        val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
                        // Add this to open the management GUI specific to this app.
                        intent.data = Uri.parse("package:$packageName")
                        startActivityForResult(intent, REQUEST_CODE_OVERLAY_PERMISSION)
                        requestOverlayPermission = true
                        // Handle the permission require result in #onActivityResult();
                    } catch (e: Exception) {
                        e.printStackTrace()
                    }
                }
            }
        } else {
            doShowFloatWindow()
        }
    }

    /**
     * 显示悬浮窗
     */
    fun doShowFloatWindow() {
//        if (EaseCallKit.getInstance().getCallState() !== EaseCallState.CALL_ANSWERED) {
//            ToastUtils.showLong("未接通时不能设置悬浮窗")
//            return
//        }
        if (chronometer != null) {
            CallFloatWindow.getInstance().setCostSeconds(chronometer.getCostSeconds())
        }
        CallFloatWindow.getInstance(MyAppLication.context)
            .setmBase(chronometer.getBase())
        CallFloatWindow.getInstance().show()
        var surface = true
        if (isIncomingCall) {
            surface = false
        }
        CallFloatWindow.getInstance().update(!true, 0, 0, surface)
        CallFloatWindow.getInstance().setCameraDirection(true, true)
        moveTaskToBack(false)
    }

    override fun onDestroy() {
        super.onDestroy()
        CallFloatWindow.getInstance().dismiss()

    }
}

总结:moveTaskToBack(false)方法是将activity放在后台,checkFloatIntent作用检测之前是否有悬浮窗,保持状态的恢复;

复制代码
MyAppLication.application.getLifecycleCallbacks().makeMainTaskToFront(this)

这个方法是将当前页面挂起回到上个页面,一定要注意恢复。

翻译

搜索

复制

相关推荐
拭心10 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王13 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡13 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道13 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库14 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道15 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe15 小时前
Android Hook - 动态加载so库
android
居居飒15 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He18 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗19 小时前
Android笔试面试题AI答之Android基础(1)
android