24 February 2018

Альтернатива платному отключению рекламы в бесплатном приложении Android

Development for AndroidDeveloping for Arduino
Sandbox
Доброго времени суток, Хабрахабр!

Меня зовут Александр, я разработчик под ОС Android. Сегодня хочу с вами поделиться опытом реализации альтернативного платному способу отключения рекламы в приложении — отключение рекламы за просмотр рекламы (AdMob Rewarded Video Ads). Интересно? Тогда добро пожаловать под кат.


Как все было ?


В далеком 2013 году я решил заняться разработкой приложений под Android, начал читать тематические книги, статьи, смотрел видео уроки и т.д. Написал первое недоприложение и приуныл, т.к. хотелось сделать что-то полезное, нужное обществу, а идей не было. В 2014 меня знакомый попросил разработать для него мобильный справочник по синтаксису платформы Arduino (там С язык). С огромным желанием я взялся за этот проект и реализовал первую версию для Android 3.0+. Через время решено было усовершенствовать ее, и так появилась вторая версия (для Android 4.0+). Обе они бесплатные с баннером от AdMob внизу и платным его отключением. Все было хорошо, пока мне не стали писать, что ~150-170 рублей РФ дороговато для отключения рекламы навсегда в их любимом справочнике. На что я ответил «бартерным» решением вопроса — пользователь может отключить баннер внизу на время за просмотр видео рекламы от AdMob.
[вернуться к содержанию]

Реализация, часть 1: Принцип работы (словами)


При запуске приложения пользователю будет показан Dialog, с предложением отключить рекламу, если, конечно, он ранее ее не отключил или отсутствует подключение к сети Интернет. В случае положительного ответа, приложение показывает фрагмент с кнопками, с помощью которых и можно выполнить отключение рекламы в приложении удобным способом.
[вернуться к содержанию]

Реализация, часть 2: Внешний вид


Диалог с предложением отключить рекламу
Диалог с предложением отключить рекламу

Экран отключения рекламы
Экран отключения рекламы

1 видео реклама просмотрена
1 видео реклама просмотрена

5 видео реклам просмотрено
5 видео реклам просмотрено

Реклама отключена на 1 час
Реклама отключена на 1 час

Реклама отключена на 1 день
Реклама отключена на 1 день

[вернуться к содержанию]

Реализация, часть 3: Принцип работы (java код)


Код главной Activity

public class ActivityMain extends AppCompatActivity  {
    private boolean internet;
    private boolean isAdsDisabled;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // get SharedPreferences
        prefManager = new PreferencesManager(this);
        isAdsDisabled = prefManager.isAdsDisabled(); // true - disable | false - enabled
        // ... здесь код создания Вашего UI

        internet = CheckURLConnection.isNetworkAvailable();
        // true - disable | false - enabled
        if (internet && !isAdsDisabled && isTimeUp()) {
            DialogFragment disableAds = new DisableAdsDialog();
            if (!disableAds.isResumed()) {
                disableAds.show(getSupportFragmentManager(), ConstantHolder.DIALOG_DISABLE_ADS);
            }
        }

    private boolean isTimeUp() {
        return System.currentTimeMillis() > prefManager.getEstimatedAdsTime();
    }
}


Код класса CheckURLConnection
public class CheckURLConnection {

    public static boolean isNetworkAvailable() {
        ConnectivityManager connectivityManager = (ConnectivityManager)
                MyAppClass.getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
        if (connectivityManager != null) {
            NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
            return activeNetworkInfo != null && activeNetworkInfo.isConnected();
        } else {
            return false;
        }
    }
}


Код класса PreferencesManager

public class PreferencesManager {

    private Context mContext;

    private static SharedPreferences mSPref;
    private SharedPreferences.Editor mSPEditor;

    public PreferencesManager(Context context) {
        this.mContext = context;
        mSPref = mContext.getSharedPreferences(ConstantHolder.APP_PREF, Context.MODE_PRIVATE);
    }

    // получаем значение состояния рекламы из SharedPreferences
    public boolean isAdsDisabled() {
        return mSPref.getBoolean(ConstantHolder.APP_PREF_DISABLE_ADS, false);
    }

    // получаем дату в миллисекундах, когда нужно включить рекламу
    public long getEstimatedAdsTime() {
        return mSPref.getLong(ConstantHolder.APP_DISABLE_ADS_PERIOD, 0);
    }
}

Класс ConstantHolder — класс, в котором я храню константы, чтобы не импортировать их отовсюду, а только из одного места брать (аналог класса R)

public class ConstantHolder {

    //Preferences Constants
    public static final String APP_PREF = "app_pref";
    public static final String APP_PREF_DISABLE_ADS = "disableAds";         // Реклама
    public static final String APP_DISABLE_ADS_PERIOD = "disableAdsPeriod"; // Период отключения рекламы
}

И самое интересное — класс-фрагмент отключения рекламы

java код целого класса
public class SettingsAdsFrag extends Fragment
        implements View.OnClickListener {


    private static final String VIEWED_ZERO_VIDEO_ADS = "0";
    private static final int VIEWED_ADS_NUMBER_PER_HOUR = 1;
    private static final int VIEWED_ADS_NUMBER_PER_DAY = 5; //5
    private static final long DISABLE_ADS_PERIOD_1_HOUR     =      60 * 60 * 1000;
    private static final long DISABLE_ADS_PERIOD_24_HOURS   = 24 * 60 * 60 * 1000;


    private Context mContext;
    private PreferencesManager prefManager;
    private RewardedVideoAd mRewardedVideoAd;
    private AdRequest mAdRequest;


    private boolean internet;
    private boolean readyToPurchase;
    private boolean bDisableAds;
    private String ready;
    private String notReady;
    private static int adsViewedCounter = 0;


    private Button btnReadyToViewing;
    private Button btnDisableAdsPerHour;
    private Button btnDisableAdsPerDay;
    private TextView tvViewedAds;
    private TextView tvEstimatedDate;
    private ToggleButton tbDisableAds;

    private BillingProcessor bp;

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
        this.mContext = context;
		// инициализируем свой класс менеджер хранения настроек
        prefManager = new PreferencesManager(context);
		// получаем текущее состояние интернет соединения
        internet = CheckURLConnection.isNetworkAvailable();
		// присваиваем "не готово" биллингу
        readyToPurchase = false;
		// сохраняем в глобальные переменные значения "НЕ ГОТОВО" и "СМОТРЕТЬ" из ресурсов. Сделал так, чтобы по несколько раз к ресурсам не обращаться
        ready = context.getString(R.string.txt_cat_ads_ready_for_viewing);
        notReady = context.getString(R.string.txt_cat_ads_not_ready_for_viewing);
    }



    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
		// отключаю меню у Toolbar
        setHasOptionsMenu(false);
		// инициализирую биллинг с помощью библиотеки от Anjlab - In-App-Billing-v3
        bp = new BillingProcessor(getActivity(),
                InAppBillingResources.getRsaKey(),     // мой RSA ключ
                InAppBillingResources.getMerchantId(), // мой ID продавца из Google Play Developer Console
                bpHandler // и сам хэндлер
				);
		// получаю состояние рекламы из файла настроек
        bDisableAds = prefManager.isAdsDisabled(); // true - disable | false - enabled
    }



    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {

        // здесь код вывода заголовков в Toolbar, опустил его, т.к. не по теме статьи


        // [START AdMob Rewarded Video Ads - инициализация]
        mRewardedVideoAd = MobileAds.getRewardedVideoAdInstance(getActivity());
        mRewardedVideoAd.setRewardedVideoAdListener(rewardedVideoAdListener);

	// такую хитрость я применяю везде, чтобы повторно Google меня не забанил на AdMob (еще 1 год писать апелляции я не хочу!)
        if (BuildConfig.DEBUG) {            
			mAdRequest = new AdRequest.Builder()
					.addTestDevice(DeviceHash.getHtcDeviceHash())
					.build();
        } else {
            mAdRequest = new AdRequest.Builder()
                    .build();
        }

	// загружаю видео рекламу
        loadRewardedVideoAd();
        // [END AdMob Rewarded Video Ads]


	// создаем View экрана Настройки - Отключение рекламы
        View settAdsView = inflater.inflate(R.layout.frag_sett_ads_screen, container, false);

        // [START ToggleButton Disable Ads]
        tbDisableAds = (ToggleButton) settAdsView.findViewById(R.id.tb_disable_ads);
        // если биллинг инициалирован  и отключение рекламы  куплено, то сохраняем это в SharedPrefereces и устанавливаем "Отключено" на кнопке-переключателе
	// да-да-да, я еще раз делаю запрос в Google на предмет покупки. А вдруг юзер руками подправил файл настроек ?
        if (readyToPurchase) {
            if (bp.isPurchased(InAppBillingResources.getSKU_DisableAds())) {
                setAdsDisable();
                tbDisableAds.setChecked(false);
            }
        } else {
            // в противном случае читаю то, что записано было
            // true - disable | false - enabled
            tbDisableAds.setChecked(!bDisableAds);
        }
	// устанавливаю слушатель нажатия по кнопке
        tbDisableAds.setOnClickListener(this);
        // [END ToggleButton Disable Ads]


	// далее идет элементарная инициализация полей и установка значений для каждой из них. Ничего сложного
        // [START TextView Rewarded Video Ads Disabling Guide]
        TextView tvRewardedGuide = (TextView) settAdsView.findViewById(R.id.tv_rewarded_video_disabling_guide);

        tvRewardedGuide.setText(String.format(getActivity().getString(R.string.txt_cat_ads_disable_tmp_text),
                VIEWED_ADS_NUMBER_PER_HOUR,
                VIEWED_ADS_NUMBER_PER_DAY));
        // [END TextView Rewarded Video Ads Disabling Guide]

        // [START TextView Viewed Ads]
        tvViewedAds = (TextView) settAdsView.findViewById(R.id.tv_viewed_ads);
        // [END TextView Viewed Ads]


        // [START Button Ready for Viewing]
        btnReadyToViewing = (Button) settAdsView.findViewById(R.id.btn_ready_to_viewing);
        btnReadyToViewing.setText(notReady);
        btnReadyToViewing.setEnabled(false);
        btnReadyToViewing.setOnClickListener(this);
        // [END Button Ready for Viewing]


        // [START Button Disable Ads Per Hour]
        btnDisableAdsPerHour = (Button) settAdsView.findViewById(R.id.btn_disable_ads_per_hour);
        btnDisableAdsPerHour.setEnabled(false);
        btnDisableAdsPerHour.setOnClickListener(this);
        // [END Button Disable Ads Per Hour]


        // [START Button Disable Ads Per Day]
        btnDisableAdsPerDay = (Button) settAdsView.findViewById(R.id.btn_disable_ads_per_day);
        btnDisableAdsPerDay.setEnabled(false);
        btnDisableAdsPerDay.setOnClickListener(this);
        // [END Button Disable Ads Per Day]


        // [START TextView Last Viewing Time]
        tvEstimatedDate = (TextView) settAdsView.findViewById(R.id.tv_estimated_date);
        // [END TextView Last Viewing Time]


	// обновляю значения текстовых полей и текста кнопок
        updateTextView();
        updateButtons();

        return settAdsView;
    }



    @Override
    public void onResume() {
        // Rewarded Video Ad - необходимо для поддержания жизненного цикла видео рекламы
        if (mRewardedVideoAd != null) {
            mRewardedVideoAd.resume(getActivity());
        }
        super.onResume();
        // updateUI - обновляю экран
        updateTextView();
        updateButtons();
    }



    @Override
    public void onPause() {
        // Rewarded Video Ad - необходимо для поддержания жизненного цикла видео рекламы
        if (mRewardedVideoAd != null) {
            mRewardedVideoAd.pause(getActivity());
        }
        super.onPause();
    }


    
    @Override
    public void onDestroy() {
        // Rewarded Video Ad - необходимо для поддержания жизненного цикла видео рекламы
        if (mRewardedVideoAd != null) {
            mRewardedVideoAd.destroy(getActivity());
        }
        super.onDestroy();
    }



    // обработчик нажатий по кнопкам
    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.tb_disable_ads:
                // disable ads ? setText(..OFF) : setText(..ON)
                // if state ON (disableAds - false)
                // true - ads disabled; false - ads enabled
                if (!bDisableAds && readyToPurchase) {
                    // если реклама не отключена и биллинг готов, выполняем покупку "Отключить рекламу навсегда платно"
                    bp.purchase(getActivity(), InAppBillingResources.getSKU_DisableAds());
                }
                break;
            case R.id.btn_ready_to_viewing:
                // если видео реклама загружена, то запускаем ее просмотр
                if (mRewardedVideoAd.isLoaded()) {
                    mRewardedVideoAd.show();
                }
                break;
            case R.id.btn_disable_ads_per_hour:
                // отключаем рекламу 1 час
                disableAdsPerPeriod(DISABLE_ADS_PERIOD_1_HOUR);

                adsViewedCounter--;

                updateTextView();
                updateButtons();

                break;
            case R.id.btn_disable_ads_per_day:
                // отключаем рекламу 1 день
                disableAdsPerPeriod(DISABLE_ADS_PERIOD_24_HOURS);

                clearAdsCounter();
                updateTextView();
                updateButtons();

                break;
            default:
                break;
        }
        // true - ads disabled;
        // false - ads enabled
        if (bDisableAds) {
            tbDisableAds.setChecked(false);
            showSnackbar();
        }
    }



    // ==========================================================
    // [START        R E W A R D E D        V I D E O        A D]
    private RewardedVideoAdListener rewardedVideoAdListener = new RewardedVideoAdListener() {
        @Override
        public void onRewardedVideoAdLoaded() {		
            // когда видео реклама будет полностью загружена, влючаем кнопку просмотра
            btnReadyToViewing.setText(ready);
            btnReadyToViewing.setEnabled(true);
        }

        @Override
        public void onRewardedVideoAdOpened() {
        }

        @Override
        public void onRewardedVideoStarted() {		
            // устанавливаем НЕ ГОТОВО на кнопку и выключаем ее
            btnReadyToViewing.setText(notReady);
            btnReadyToViewing.setEnabled(false);
        }

        @Override
        public void onRewardedVideoAdClosed() {
	    // загружаем новую видео рекламу
            loadRewardedVideoAd();
        }

        @Override
        public void onRewarded(RewardItem rewardItem) {
	    // если счетчик рекламы меньше количества просмотров для отключения на день, то инкрементируем его
            if (adsViewedCounter < VIEWED_ADS_NUMBER_PER_DAY) {
                adsViewedCounter++;
            }

	    // обновляем поля экрана
            updateTextView();
            updateButtons();
        }

        @Override
        public void onRewardedVideoAdLeftApplication() {
        }

        @Override
        public void onRewardedVideoAdFailedToLoad(int i) {
	    // загружаем новую рекламу
            loadRewardedVideoAd();
        }
    };

    private void loadRewardedVideoAd() {
	 // если есть доступ в сеть Интернет, грузим видео рекламу
        if (internet) {
            mRewardedVideoAd.loadAd(mContext.getString(R.string.admob_rewarded_video_id), mAdRequest);
        }
    }

    // [END        R E W A R D E D        V I D E O        A D]
    // ==========================================================



	// обновляем текстовые поля согласно условий и значения счетчика просмотров
	// показываем дату возобновления рекламы в приложении, если ее отключили временно
    private void updateTextView() {

        // true - disable | false - enabled
        if (bDisableAds) {
            tvViewedAds.setText(String.valueOf(adsViewedCounter));
            tvEstimatedDate.setText("");
        } else {
            // [START        U P D A T E        T E X T V I E W :    tvViewedAds]
            if (adsViewedCounter > 0 && adsViewedCounter <= VIEWED_ADS_NUMBER_PER_DAY) {
                String strViewedAdsCount = adsViewedCounter + " / " + VIEWED_ADS_NUMBER_PER_DAY;
                tvViewedAds.setText(strViewedAdsCount);
            } else {
                tvViewedAds.setText(VIEWED_ZERO_VIDEO_ADS);
            }
            // [END        U P D A T E        T E X T V I E W :    tvViewedAds]


            // [START        U P D A T E        T E X T V I E W :    tvEstimatedDate]
            long estimatedDate = prefManager.getEstimatedAdsTime();
            long currentDate = System.currentTimeMillis();
            if (estimatedDate != 0 && estimatedDate > currentDate) {
                String strEstimatedDate = convertTime(estimatedDate);
                String strEstimatedDateFinal = "<b>" + mContext.getString(R.string.txt_tv_header_estimated_time).toUpperCase() + "</b>"
                        + "<br>"
                        + strEstimatedDate;
                tvEstimatedDate.setText(Html.fromHtml(strEstimatedDateFinal));
            }
            // [END        U P D A T E        T E X T V I E W :    tvEstimatedDate]           
        }
    }



	// обновляем кнопки
    private void updateButtons() {
        // 0
        if (adsViewedCounter == 0) {
            btnDisableAdsPerHour.setEnabled(false);
            btnDisableAdsPerDay.setEnabled(false);
        }
        // 1 - 4
        if (adsViewedCounter > 0 && adsViewedCounter < VIEWED_ADS_NUMBER_PER_DAY) {
            btnDisableAdsPerHour.setEnabled(true);
        }
        // 5
        if (adsViewedCounter == VIEWED_ADS_NUMBER_PER_DAY) {
            btnDisableAdsPerHour.setEnabled(true);
            btnDisableAdsPerDay.setEnabled(true);
        }
    }



	// метод отключения рекламы на период
    private void disableAdsPerPeriod(long disablePeriod) {
	 // текущая дата в миллисекундах
        long currentDate = System.currentTimeMillis();
	 // дата возобновления рекламы в приложении
        long estimatedDate = currentDate + disablePeriod;
	 // сохраняем дату в файл настроек
        prefManager.setEstimatedDate(estimatedDate);

		// отключаем баннер внизу экрана
        AdMobAds.disableBanner(getActivity(), true);
    }



	// обнуляем счетчик
    private void clearAdsCounter() {
        adsViewedCounter = 0;
    }



	// конвертер миллисекунд в дату и время согласно формату
    public String convertTime(long time) {
        Date date = new Date(time);
        Format format = new SimpleDateFormat("dd MMM yyyy @ HH:mm:ss");
        return format.format(date);
    }



    // ==========================================================
    // [START        IN        APP         BILLING]
    BillingProcessor.IBillingHandler bpHandler = new BillingProcessor.IBillingHandler() {
        @Override
        public void onProductPurchased(@NonNull String productId, @Nullable TransactionDetails details) {
            // Called when requested PRODUCT ID was successfully purchased
            // Вызывается, когда запрашиваемый PRODUCT ID был успешно куплен

            if (bp.isPurchased(productId)) {
		 // сохраняем новое состояние рекламы "отключена" и устанавливаем "Выключено" для кнопки-переключателя
                setAdsDisable();
                tbDisableAds.setChecked(false);
                // перезапускаем приложение
                restartDialog();
            } else {
				// иначе устанавливаем "Включено"
                tbDisableAds.setChecked(true);
            }
        }

        @Override
        public void onPurchaseHistoryRestored() {
            //Вызывается, когда история покупки была восстановлена,
            // и список всех принадлежащих идентификаторы продуктов был загружен из Google Play
        }

        @Override
        public void onBillingError(int errorCode, @Nullable Throwable error) {
            // Вызывается, когда появляется ошибка. См. константы класса
            // для получения более подробной информации
        }

        @Override
        public void onBillingInitialized() {
            // Вызывается, когда bp был инициализирован и он готов приобрести
            readyToPurchase = true;
        }
    };
    // [START        IN        APP         BILLING]
    // ==========================================================



	// метод сохранения отключенного состояния рекламы
    private void setAdsDisable() {
        prefManager.setAdsDisabled();
    }



    // диалог перезапуска приложения
    // [START restartDialog]
    private void restartDialog() {
        AlertDialog.Builder builder;

        View alertLayout = View.inflate(mContext, R.layout.dialog_restart, null);
        if (prefManager.getAppTheme() == 0) {
            builder = new AlertDialog.Builder(getActivity(), R.style.AppThemeDialogStyleLight);
        } else {
            builder = new AlertDialog.Builder(getActivity(), R.style.AppThemeDialogStyleDark);
        }

        builder.setTitle(getActivity().getString(R.string.msg_notification_Title));
        builder.setView(alertLayout);

        builder.setPositiveButton(getActivity().getString(R.string.ans_restart),
                new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        restartApp();
                    }
                });
        builder.show();
    }
    // [END restartDialog]



    // метод перезапуска приложения
    // [START restartApp]
    private void restartApp() {
        Intent i = getActivity().getPackageManager().getLaunchIntentForPackage(getActivity().getPackageName());
        if (i != null) {
            i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
            getActivity().startActivity(i);
        }
    }
    // [END restartApp]



    // Snackbar с уведомлением, что рекламу уже отключена. Если пользователь снова кликнет по кнопке отключения рекламы
    private void showSnackbar() {
        Snackbar.make(getActivity().getWindow().getDecorView().getRootView(),
                getActivity().getResources().getString(R.string.advertising_is_already_disabled),
                Snackbar.LENGTH_SHORT).show();
    }
}


Класс работы с баннером AdMob. Ничего сложного, публикую для ознакомления. Хотя его же и на StackOverFlow ни раз выкладывал

public class AdMobAds {

    public static void disableBanner(final Activity activity, boolean disableAds) {

        final View adsContainer = activity.findViewById(R.id.container);
        final AdView adView = (AdView) activity.findViewById(R.id.adView);

        if (disableAds) {
            adView.setVisibility(View.GONE);
            adsContainer.setPadding(0, 0, 0, 0);
        } else {
            AdRequest adRequest;
            if (BuildConfig.DEBUG) {                
                    adRequest = new AdRequest.Builder()
                            .addTestDevice(DeviceHash.getHtcDeviceHash())
                            .build();
            } else {
                adRequest = new AdRequest.Builder()
                        .build();
            }
            adView.loadAd(adRequest);

            adView.setAdListener(new AdListener() {
                @Override
                public void onAdFailedToLoad(int errorCode) {
                    MyAppLogs.show("[bottom-banner] >> onAdFailedToLoad: реклама не загружена\terrorCode = " + errorCode + ".");
                    super.onAdFailedToLoad(errorCode);
                }

                @Override
                public void onAdLoaded() {
                    super.onAdLoaded();
                    MyAppLogs.show("[bottom-banner] >> onAdLoaded");
                    if (adView.getVisibility() == View.GONE) {
                        adView.setVisibility(View.VISIBLE);
                    }
                    View adsContainer = activity.findViewById(R.id.container);
                    adsContainer.setPadding(adsContainer.getPaddingLeft(),
                            adsContainer.getPaddingTop(),
                            adsContainer.getPaddingRight(),
                            adView.getHeight() + 8);
                }
            });
        }
    }
}

[вернуться к содержанию]

Заключение


Вот и все. Суть статьи — поделиться с обществом своей идеей и ее реализацией. А также получить приглашение на Хабрахабр, если кому-то понравится то, чем я поделился. Буду рад и благодарен за ваше мнение в вопросах доработки кода и/или идеи. Если понадобится дополнительное пояснение — пишите, я в кратчайшие сроки внесу правки в статью или дам ответ в комментариях.

Ссылку на приложение по понятным причинам не публикую в открытом доступе. Этика есть этика! Кому нужно — дам в лс.

Статистика AdMob пока еще сырая, с момента внедрения данной альтернативы прошло всего ~2 недели. Не все пользователи обновились. Но точно есть те, кто пользуется таким способом отключения баннера внизу.

UPDATE: Ссылка на статью с результатами ИТОГ 3-х месяцев: Альтернатива платному отключению рекламы в бесплатном приложении Android

Благодарю всех, кто дочитал статью до конца!
Tags:androidadmobrewarded videoсправочникarduino
Hubs: Development for Android Developing for Arduino
+22
18.3k 89
Comments 97
Popular right now
Android
from 150,000 ₽NatsONМоскваRemote job
Android-разработчик
from 80,000 ₽FlowwowRemote job
Android developer
from 100,000 to 150,000 ₽King Bird StudioМоскваRemote job
Android-разработчик
to 80,000 ₽AmigowebМагнитогорскRemote job
Senior Software Engineer - Kotlin
from 3,000 to 4,500 $UscreenRemote job
Top of the last 24 hours