카테고리 보관물: Android

안드로이드 화면에 실행중인 앱 확인하는 방법

안드로이드 5(롤리팝) 이후부터는 안드로이드 기기에 실행중인 앱을 알 수 없게 되었습니다. 그래서, 안드로이드 5 이후 버전에서는 기기에 저장하고 있는 통계 데이터를 기반으로 완전히 정확하지는 않지만, 대략적으로 정확한 정보를 가져올 수 있습니다. UsageStatsManager를 사용해서 현재 안드로이드 기기의 화면에 실행중인 앱을 검색하는 방법을 살펴보자.

아래는 UsageStatsManager를 사용해서 안드로이드 기기에 저장되어 있는 이벤트 데이터를 검색하고, 이벤트가 포그라운드(Event.MOVE_TO_FOREGROUND 또는 Event.ACTIVITY_RESUMED)인 것을 확인해서 마지막 포그라운드 이벤트에 해당하는 패키지 이름을 가져온다. 이 정보는 대체로 정확하고, 아래 코드로 가져온 패키지가 현재 화면에서 실행중인 것을 확인 할 수 있다.

1. 앱의 AndroidManifest.xml에 아래의 권한을 추가


2. 안드로이드 기기에서 실행중인 앱 가져오기

public static String getTopPackageName(@NonNull Context context) {
  UsageStatsManager usageStatsManager = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE);

  long lastRunAppTimeStamp = 0L;

  final long INTERVAL = 1000 * 60 * 5;
  final long end = System.currentTimeMillis();
  // 1 minute ago
  final long begin = end - INTERVAL;

  LongSparseArray packageNameMap = new LongSparseArray<>();
  final UsageEvents usageEvents = usageStatsManager.queryEvents(begin, end);
  while (usageEvents.hasNextEvent()) {
    UsageEvents.Event event = new UsageEvents.Event();
    usageEvents.getNextEvent(event);

    if(isForeGroundEvent(event)) {
      packageNameMap.put(event.getTimeStamp(), event.getPackageName());
      if(event.getTimeStamp() > lastRunAppTimeStamp) {
        lastRunAppTimeStamp = event.getTimeStamp();
      }
    }
  }

  return packageNameMap.get(lastRunAppTimeStamp, "");
}

3. 이벤트가 포그라운드 이벤트인지 확인

private static boolean isForeGroundEvent(UsageEvents.Event event) {
  if(event == null) {
    return false;
  }

  if(BuildConfig.VERSION_CODE >= 29) {
    return event.getEventType() == UsageEvents.Event.ACTIVITY_RESUMED;
  }

  return event.getEventType() == UsageEvents.Event.MOVE_TO_FOREGROUND;
}

위 코드를 사용해서 완전히 정확하지는 않지만, 대체로 정확하게 안드로이드 화면에서 실행중인 앱을 알 수 있다.

안드로이드 컨텍스트(Context)

안드로이드 컨텍스트(Context)는 리소스에 접근하는 인터페이스이고, 이 인터페이스는 크게 애플리케이션(Application), 액티비티(Activity), 컨텍스트 래퍼(ContextWrapper)의 베이스 컨텍스트(BaseContext)로 3가지 형태를 가진다. 안드로이드를 개발하는 데 있어 특정 상황에서 어떤 컨텍스트를 사용해야 하는지 아는 것은 매우 중요하다. 그래서, 개별 컨텍스트에 대해서 살펴보자.

애플리케이션 컨텍스트
– 애플리케이션은 컨텍스트는 앱 프로세스 자체라고 생각하면 쉽다. 애플리케이션 클래스 자체가 애플리케이션이기 때문에 안드로이드 앱 전반에 걸쳐서 사용할 수 있다. 그리고, 애플리케이션 클래스가 애플리케이션 컨텍스트이기에 라이프 사이클이 애플리케이션하고 같다. getApplicationContext()나 Application 클래스를 상속한 클래스 객체를 사용하면 된다.

액티비티
– 액티비티 컨텍스트도 액비티비 자체라고 생각하면 쉽다. 프레그먼트(Fragment)나 뷰에서 사용할 수 있는 컨텍스트이다. 일반적으로 안드로이드 뷰와 관련된 작업을 하는 데 사용한다. 액티비티 컨텍스트로 액티비티 자체이기에 라이프 사이클이 액티비티와 같다. 보통 액티비티에서는 this, 프레그먼트에서는 getContext()나 getActivity(), 그리고 뷰에서는 getContext()로 컨텍스트를 사용하면 된다.

베이스 컨텍스트
– 자신의 컨텍스트가 아닌 다른 컨텍스트를 접근하기 위해서 사용한다. 보통 getBaseContext()로 컨텍스트를 사용하면 된다.  [ 그림 1. 컨텍스트 클래스 다이어그램 ]

위 그림은 컨텍스트를 어떤 안드로이드 구성요소가 구현하고 있는지 알 수 있고, 위에서 설명한 컨텍스트 객체가 왜 애플리케이션, 액티비티, 컨텍스트 래퍼의 베이스 컨텍스트인지 알 수 있다.

스택오버플로어 사이트에서, 안드로이드 컨텍스트와 사용할 수 있는 기능들을 정리한 이미지를 살펴보면 다음과 같다.


[ 그림 2. 컨텍스트 ]


[ 그림 3. 개별 컨텍스트에서 사용할 수 있는 기능들 ]

안드로이드에서 제공하는 컨텍스트에 대해서 살펴봤다. 그리고 개별 컨텍스트가 제공할 수 있는 기능별 제약이 있기에, 잘 알아서 여러 상황에 맞는 컨텍스트를 사용해야 한다.

레퍼런스
https://stackoverflow.com/questions/3572463/what-is-context-on-android

안드로이드 앱이 동작하는 폼 팩터(Form Factor) 확인 방법

폼 팩터(Form Factor)의 정의를 위키피디아에서 살펴보면 아래와 같다.

컴퓨터 시스템의 각 부품의 물리적 치수의 형태를 의미한다.

즉, 소프트웨어가 동작하는 기기의 형태를 말하는 것이다. 안드로이드 적용범위가 확대되면서, 안드로이드 앱이 실행할 수 있는 하드웨어 기기가 많아졌다. 예로, 크롬북이나 TV등을 예로 들 수 있다.

최근에 안드로이드 앱을 크롬북과 삼성 덱스(DEX)의 UI/UX에 어울리도록 작업을 하면서 특정 기능(키보드 등)은 모바일/태블릿에서만 필요하다. 그래서 앱이 동작하는 기기의 정보를 확인해야 했고, 아래의 코드로 해결을 했다.

아래 코드는 구글 TalkBack 깃헙 사이트의 https://github.com/google/talkback/blob/master/utils/src/main/java/FormFactorUtils.java 코드에 삼성 덱스(DEX)를 확인하는 코드를 추가했다.

아래의 ARC는 App Runtime for Chrome의 줄임말으로 폼 팩터가 크롬북이라는 것을 알 수 있다.

import android.app.UiModeManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.os.Build;

public class FormFactorUtils {
	private static final int FORM_FACTOR_PHONE_OR_TABLET = 0;
	private static final int FORM_FACTOR_WATCH = 1;
	private static final int FORM_FACTOR_TV = 2;
	private static final int FORM_FACTOR_ARC = 3;

	private static final int FORM_FACTOR_SAMSUNG_DEX = 100;

	private static final String ARC_DEVICE_PATTERN = ".+_cheets|cheets_.+";

	private static FormFactorUtils sInstance;

	private final int mFormFactor;
	private final boolean mHasAccessibilityShortcut;

	private FormFactorUtils(final Context context) {
		// Find device type.
		if (context.getApplicationContext()
				.getPackageManager()
				.hasSystemFeature(PackageManager.FEATURE_WATCH)) {
			mFormFactor = FORM_FACTOR_WATCH;
		} else if (isContextTelevision(context)) {
			mFormFactor = FORM_FACTOR_TV;
		} else if (Build.DEVICE != null && Build.DEVICE.matches(ARC_DEVICE_PATTERN)) {
			mFormFactor = FORM_FACTOR_ARC;
		} else if(isContextDex(context)) {
			mFormFactor = FORM_FACTOR_SAMSUNG_DEX;
		} else {
			mFormFactor = FORM_FACTOR_PHONE_OR_TABLET;
		}

		// Find whether device supports accessibility shortcut.
		mHasAccessibilityShortcut = (BuildVersionUtils.isAtLeastO() && mFormFactor == FORM_FACTOR_PHONE_OR_TABLET);
	}

	/** @return an instance of this Singleton. */
	public static synchronized FormFactorUtils getInstance(final Context context) {
		if (sInstance == null) {
			sInstance = new FormFactorUtils(context);
		}
		return sInstance;
	}

	/** Return the cached version of the isWatch. */
	public boolean isWatch() {
		return mFormFactor == FORM_FACTOR_WATCH;
	}

	public boolean isArc() {
		return mFormFactor == FORM_FACTOR_ARC;
	}

	public boolean isTv() {
		return mFormFactor == FORM_FACTOR_TV;
	}

	public boolean isPhoneOrTablet() {
		return mFormFactor == FORM_FACTOR_PHONE_OR_TABLET;
	}

	public boolean isDex() {
		return mFormFactor == FORM_FACTOR_SAMSUNG_DEX;
	}

	public boolean hasAccessibilityShortcut() {
		return mHasAccessibilityShortcut;
	}

	public static boolean useSpeakPasswordsServicePref() {
		return BuildVersionUtils.isAtLeastO();
	}

	public static boolean isContextTelevision(Context context) {
		if (context == null) {
			return false;
		}

		UiModeManager modeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
		return modeManager != null
				&& modeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
	}

	private static boolean isContextDex(Context context) {
		if (context == null) {
			return false;
		}

		try {
			Configuration config = context.getResources().getConfiguration();
			Class configClass = config.getClass();
			if (configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass)
					== configClass.getField("semDesktopModeEnabled").getInt(config)) {
				return true;
			}
		} catch (Exception e) {
			// ignore
		}

		return false;
	}
}

웹뷰(WebView)에 OnClick 이벤트 추가하기

웹뷰(WebView)는 View를 상속받아서, 뷰에서 클릭 이벤트를 처리할 수 있도록 OnClickListener를 등록할 수 있다. 하지만, 웹뷰는 OnClickListener로 이벤트를 알려 주지 않는다. 그래서, OnClick 이벤트를 처리하기 위해서는 이 이벤트에 대한 처리를 직접해야 한다.

웹뷰의 OnClick 이벤트를 캐치하기 위해서는 OnTouch 이벤트에서 조건에 맞는 경우에 OnClick으로 간주하면 된다.

조건은 아래의 코드에서 볼 수 있듯이 다음과 같다.
– 클릭시 1초이내(1초를 넘어가면 롱클릭)에 손가락이 떨어져야 한다.
– 그리고 손가락의 이동이 15dp이내이어야 한다. 그 이상 멀어진 경우 드레그를 했다고 간주한다.

   
import android.content.Context;
import android.view.MotionEvent;
import android.view.View;

public class OnClickWithOnTouchListener implements View.OnTouchListener {
	public interface OnClickListener {
		void onClick();
	}

	/**
	 * Max allowed duration for a "click", in milliseconds.
	 */
	static final int MAX_CLICK_DURATION = 1000;
	/**
	 * Max allowed distance to move during a "click", in DP.
	 */
	static final int MAX_CLICK_DISTANCE = 15;

	private Context mContext;
	private OnClickListener mClickListener;

	private long pressStartTime;
	private float pressedX;
	private float pressedY;
	private boolean stayedWithinClickDistance;

	public OnClickWithOnTouchListener(Context context, OnClickListener clickListener) {
		this.mContext = context;
		this.mClickListener = clickListener;
	}

	@Override
	public boolean onTouch(View v, MotionEvent event) {
		if(mClickListener == null) {
			return false;
		}

		int eventAction = event.getAction();
		if(eventAction == MotionEvent.ACTION_DOWN) {
			pressStartTime = System.currentTimeMillis();
			pressedX = event.getX();
			pressedY = event.getY();
			stayedWithinClickDistance = true;
		} else if(eventAction == MotionEvent.ACTION_MOVE) {
			float distance = distance(pressedX, pressedY, event.getX(), event.getY());
			if (stayedWithinClickDistance && distance > MAX_CLICK_DISTANCE) {
				stayedWithinClickDistance = false;
			}
		} else if(eventAction == MotionEvent.ACTION_UP) {
			long pressDuration = System.currentTimeMillis() - pressStartTime;
			if (pressDuration < MAX_CLICK_DURATION && stayedWithinClickDistance) {
				mClickListener.onClick();
			}
		}

		return false;
	}

	private float distance(float x1, float y1, float x2, float y2) {
		float dx = x1 - x2;
		float dy = y1 - y2;
		float distanceInPx = (float) Math.sqrt(dx * dx + dy * dy);
		return pxToDp(mContext, distanceInPx);
	}

	public static float pxToDp(Context context, float px) {
		if(context == null) {
			return 0f;
		}

		return px / context.getResources().getDisplayMetrics().density;
	}
}

이제 위 코드를 사용해서 웹뷰의 OnClick 이벤트를 처리해 보자.

  
	mWebview.setOnTouchListener(new OnClickWithOnTouchListener(this, new OnClickWithOnTouchListener.OnClickListener() {
		@Override
		public void onClick() {
			// onclick 이벤트 처리
		}
	}));

* 레퍼런스
- https://stackoverflow.com/questions/9965695/how-to-distinguish-between-move-and-click-in-ontouchevent/17911861#17911861

FileProvider 사용시 Manifest 중복 문제

안드로이드에서 파일을 공유하기 위해서는 안드로이드 7.0(Nougat / API 24)이전에는 “file://” uri를 사용했다. 그러나 안드로이드 7.0 이후부터는 “content://” uri를 사용해야 하고, uri에 포함된 파일은 엑세스 권한을 부여하고, FileProvider를 사용해야 한다.

일반적인 코드는 아래와 같다.

   

	
	

그래서, 앱에서 파일 공유 기능을 구현하고, 의존 라이브러리가 FileProvider를 사용하면 아래의 에러를 보게된다.

Manifest merger failed with multiple errors, see logs

이 에러를 확인하기 위해서 Merged Manifest 탭을 보니, 맨 아래에 아래의 에러 메시지가 보인다.

Merging Errors:

Error: Attribute provider#android.support.v4.content.FileProvider@authorities value=(net.sjava.barcode.fileprovider) from AndroidManifest.xml:83:13-65 is also present at AndroidManifest.xml:14:13-60 value=(net.sjava.barcode.provider). Suggestion: add ‘tools:replace=”android:authorities”‘ to element at AndroidManifest.xml:81:9-91:20 to override. app main manifest (this file), line 82

Error: Attribute meta-data#android.support.FILE_PROVIDER_PATHS@resource value=(@xml/file_provider_paths) from AndroidManifest.xml:89:17-60 is also present at AndroidManifest.xml:19:17-55 value=(@xml/provider_paths). Suggestion: add ‘tools:replace=”android:resource”‘ to element at AndroidManifest.xml:87:13-90:20 to override. app main manifest (this file), line 88

위 메시지는 ‘tools:replace=”android:authorities”‘를 요소에 추가하고, ‘tools:replace=”android:resource”‘는 요소에 추가하면 된다고 한다. 그래서 안드로이드 스튜디오가 알려주는 대로 이 두 개의 속성을 추가하면 컴파일 에러가 발생하지 않는다.

이 설정은 아래와 같다.

   

	

마지막으로 위 코드처럼 “tools:replace”는 늘 포함시키면 좋겠다.